issue-274 #278
181
docs/blossom-flow.drawio
Normal 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="<span style="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;">Servers: A, B, C</span>" 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="<span style="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;">Servers: D, E</span>" 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="<span style="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;">File Servers (Blossom)</span>" 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="<span style="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;">Sigits</span>" 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="<span style="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;">User App Data</span>" 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="<span style="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;">Servers: A, B, C</span>" 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="<p style="margin:0px;margin-top:4px;text-align:center;text-decoration:underline;"><b>interface UserAppData</b></p><hr><p style="margin: 0px 0px 0px 8px; font-size: 14px;">sigits<br style="border-color: var(--border-color); text-align: center;"><span style="text-align: center;">blossomVersions</span><br style="border-color: var(--border-color); text-align: center;"><span style="text-align: center;">processedGiftWrapps</span><br style="border-color: var(--border-color); text-align: center;"><span style="text-align: center;">keyPair</span><br></p>" 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&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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"SIGIT_BLOSSOM": "https://blossom.sigit.io"
|
||||
}
|
17
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;
|
||||
}
|
||||
|
@ -13,6 +13,13 @@ import { MarkRenderSignature } from './Render'
|
||||
export const SignatureStrategy: MarkStrategy = {
|
||||
input: MarkInputSignature,
|
||||
render: MarkRenderSignature,
|
||||
/**
|
||||
* Encrypts a stringified signature object, creates an encrypted JSON file,
|
||||
* and uploads it to a file storage if the user is online.
|
||||
* @param value
|
||||
* @param encryptionKey
|
||||
* @returns the original value string
|
||||
*/
|
||||
encryptAndUpload: async (value, encryptionKey) => {
|
||||
// Value is the stringified signature object
|
||||
// Encode it to the arrayBuffer
|
||||
@ -39,9 +46,11 @@ export const SignatureStrategy: MarkStrategy = {
|
||||
|
||||
m marked this conversation as resolved
Outdated
|
||||
if (await isOnline()) {
|
||||
enes
commented
Original value holds coordinates that replicate the signature strokes and depending on the person's signature can be quite large. Original value holds coordinates that replicate the signature strokes and depending on the person's signature can be quite large.
Online flow - It's uploaded as a separate blossom file and returned as URL to be fetched when needed.
Offline - the actual value as is kept as is and not replaced by a URL since we don't care about size in this case.
m
commented
Hm, I did not see how it is fetched when mark.value is a URL, can you point to a file where that happens? Hm, I did not see how it is fetched when mark.value is a URL, can you point to a file where that happens?
enes
commented
the the `MarkStrategy` interface and `fetchAndDecrypt` and `encryptAndUpload` functions handle that
|
||||
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(', ')}`
|
||||
)
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
@ -51,7 +60,7 @@ export const SignatureStrategy: MarkStrategy = {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TOOD: offline
|
||||
// TODO: offline
|
||||
}
|
||||
|
||||
return value
|
||||
@ -85,8 +94,7 @@ export const SignatureStrategy: MarkStrategy = {
|
||||
if (arrayBuffer) {
|
||||
// decode json
|
||||
const decoder = new TextDecoder()
|
||||
const json = decoder.decode(arrayBuffer)
|
||||
return json
|
||||
return decoder.decode(arrayBuffer)
|
||||
}
|
||||
|
||||
// TOOD: offline
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
getUpdatedMark,
|
||||
updateCurrentUserMarks
|
||||
} from '../../utils'
|
||||
import { EMPTY } from '../../utils/const.ts'
|
||||
import { EMPTY } from '../../utils'
|
||||
import { Container } from '../Container'
|
||||
import signPageStyles from '../../pages/sign/style.module.scss'
|
||||
import { CurrentUserFile } from '../../types/file.ts'
|
||||
@ -20,10 +20,18 @@ import {
|
||||
faFileDownload,
|
||||
faPen
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { Typography } from '@mui/material'
|
||||
import styles from '../UsersDetails.tsx/style.module.scss'
|
||||
|
||||
interface PdfMarkingProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
files: CurrentUserFile[]
|
||||
/**
|
||||
* Currently, loading spinner is present if `files` array is of length 0,
|
||||
* Which means if no files are found, loading spinner will be spinning indefinitely
|
||||
* For that reason `noFiles` is introduced to set the loading off when fetching is finished.
|
||||
*/
|
||||
noFiles?: boolean
|
||||
handleExport: () => void
|
||||
handleEncryptedExport: () => void
|
||||
handleSign: () => void
|
||||
@ -42,6 +50,7 @@ interface PdfMarkingProps {
|
||||
const PdfMarking = (props: PdfMarkingProps) => {
|
||||
const {
|
||||
files,
|
||||
noFiles,
|
||||
currentUserMarks,
|
||||
setCurrentUserMarks,
|
||||
setUpdatedMarks,
|
||||
@ -131,6 +140,18 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
setSelectedMarkValue(value)
|
||||
}
|
||||
|
||||
const renderRightColumn = () => {
|
||||
if (meta !== null) return <UsersDetails meta={meta} />
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<Typography>No meta found</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className={signPageStyles.container}>
|
||||
@ -148,7 +169,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={meta !== null && <UsersDetails meta={meta} />}
|
||||
right={renderRightColumn()}
|
||||
leftIcon={faFileDownload}
|
||||
centerIcon={faPen}
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
Is it the case that if there are no files found in the zip, Can you also add some comments, here and in the code, to clarify what the Is it the case that if there are no files found in the zip, `files` will be an empty array? Can we rely on the length of the files array instead of introducing the `noFiles` flag?
Can you also add some comments, here and in the code, to clarify what the `noFiles` flag is meant to do?
|
||||
rightIcon={faCircleInfo}
|
||||
@ -156,21 +177,30 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
<PdfView
|
||||
currentFile={currentFile}
|
||||
files={files}
|
||||
noFiles={noFiles}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
If you keep the If you keep the `noFiles` flag, the following two evaluations should be refactored using the ternary operator.
|
||||
selectedMark={selectedMark}
|
||||
currentUserMarks={currentUserMarks}
|
||||
otherUserMarks={otherUserMarks}
|
||||
/>
|
||||
{noFiles && (
|
||||
<Typography textAlign="center">
|
||||
We were not able to retrieve the files.
|
||||
</Typography>
|
||||
)}
|
||||
</StickySideColumns>
|
||||
<MarkFormField
|
||||
handleSubmit={handleSubmit}
|
||||
handleSelectedMarkValueChange={handleChange}
|
||||
selectedMark={selectedMark}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
currentUserMarks={currentUserMarks}
|
||||
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
|
||||
/>
|
||||
|
||||
{!noFiles && (
|
||||
<MarkFormField
|
||||
handleSubmit={handleSubmit}
|
||||
handleSelectedMarkValueChange={handleChange}
|
||||
selectedMark={selectedMark}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
currentUserMarks={currentUserMarks}
|
||||
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
|
@ -4,12 +4,18 @@ import { CurrentUserFile } from '../../types/file.ts'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FileDivider } from '../FileDivider.tsx'
|
||||
import React from 'react'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/index.tsx'
|
||||
import { LoadingSpinner } from '../LoadingSpinner'
|
||||
|
||||
interface PdfViewProps {
|
||||
currentFile: CurrentUserFile | null
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
files: CurrentUserFile[]
|
||||
/**
|
||||
* Currently, loading spinner is present if `files` array is of length 0,
|
||||
* Which means if no files are found, loading spinner will be spinning indefinitely
|
||||
* For that reason `noFiles` is introduced to set the loading off when fetching is finished.
|
||||
*/
|
||||
noFiles?: boolean
|
||||
handleMarkClick: (id: number) => void
|
||||
otherUserMarks: Mark[]
|
||||
selectedMark: CurrentUserMark | null
|
||||
@ -21,6 +27,7 @@ interface PdfViewProps {
|
||||
*/
|
||||
const PdfView = ({
|
||||
files,
|
||||
noFiles,
|
||||
currentUserMarks,
|
||||
handleMarkClick,
|
||||
selectedMarkValue,
|
||||
@ -80,6 +87,8 @@ const PdfView = ({
|
||||
<FileDivider key={`separator-${i}`} />,
|
||||
curr
|
||||
])
|
||||
) : noFiles ? (
|
||||
''
|
||||
) : (
|
||||
<LoadingSpinner variant="small" />
|
||||
)}
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
faCheck,
|
||||
faClock,
|
||||
faEye,
|
||||
faServer,
|
||||
faFile,
|
||||
faFileCircleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
@ -40,6 +41,7 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
signers,
|
||||
viewers,
|
||||
fileHashes,
|
||||
zipUrls,
|
||||
signersStatus,
|
||||
createdAt,
|
||||
completedAt,
|
||||
@ -100,6 +102,18 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to parse the base URL from Blossom server full path
|
||||
*/
|
||||
const getBaseUrl = (url: string): string => {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
return `${parsedUrl.protocol}//${parsedUrl.host}`
|
||||
} catch (error) {
|
||||
return 'Invalid URL'
|
||||
}
|
||||
}
|
||||
|
||||
return submittedBy ? (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
@ -270,6 +284,20 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<FontAwesomeIcon icon={faFileCircleExclamation} /> —
|
||||
</>
|
||||
)}
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faEye} /> {signedStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<p>File Servers</p>
|
||||
|
||||
{zipUrls &&
|
||||
zipUrls.map((url) => (
|
||||
<span className={styles.detailsItem} key={url}>
|
||||
<FontAwesomeIcon icon={faServer} /> {getBaseUrl(url)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
|
@ -18,11 +18,14 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from './store'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
import { useDvm } from './useDvm'
|
||||
import { getFileServerMap } from '../utils/file-servers.ts'
|
||||
import store from '../store/store.ts'
|
||||
import { setServerMapAction } from '../store/servers/action.ts'
|
||||
|
||||
export const useAuth = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { getRelayInfo } = useDvm()
|
||||
const { findMetadata, getNDKRelayList } = useNDKContext()
|
||||
const { findMetadata, getNDKRelayList, fetchEvent } = useNDKContext()
|
||||
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const relaysState = useAppSelector((state) => state.relays)
|
||||
@ -92,31 +95,44 @@ export const useAuth = () => {
|
||||
})
|
||||
)
|
||||
|
||||
const ndkRelayList = await getNDKRelayList(pubkey)
|
||||
const [ndkRelayList, serverMap] = await Promise.all([
|
||||
getNDKRelayList(pubkey),
|
||||
eugene
commented
What is the advantage of using a map over list/array? In my view, a map is useful when it is possible to query items by key, which is not something we will do with blossom drives. An array seems a more straightforward choice. You won't need to change an object into an array further down the line when you intract with it. What is the advantage of using a map over list/array?
In my view, a map is useful when it is possible to query items by key, which is not something we will do with blossom drives.
An array seems a more straightforward choice. You won't need to change an object into an array further down the line when you intract with it.
m
commented
b
commented
let's put this explanation in a code comment let's put this explanation in a code comment
eugene
commented
You could have an entity FileServer:
Again, in my view, that is a preferred approach, because we have no ability (or reason, really) to query file servers by key. And in the majority of cases that I've seen, the fileServerMap needs to be transformed into an array of keys, values, or entries, in order to interact with it. You could have an entity FileServer:
`{ url: string,
read: boolean,
write: boolean }
`
`servers` would hold an array of FileServer objects.
Again, in my view, that is a preferred approach, because we have no ability (or reason, really) to query file servers by key. And in the majority of cases that I've seen, the fileServerMap needs to be transformed into an array of keys, values, or entries, in order to interact with it.
|
||||
getFileServerMap(pubkey, fetchEvent)
|
||||
])
|
||||
|
||||
m marked this conversation as resolved
eugene
commented
Instead of storing promises in a separate variable, you can pass in the functions that return promises directly in Instead of storing promises in a separate variable, you can pass in the functions that return promises directly in `Promise.all`.
|
||||
const relays = ndkRelayList.relays
|
||||
|
||||
if (relays.length < 1) {
|
||||
// Navigate user to relays page if relay map is empty
|
||||
// Navigate user to relays page if a relay map is empty
|
||||
return appPrivateRoutes.relays
|
||||
}
|
||||
|
||||
if (Object.keys(serverMap).length < 1) {
|
||||
// Navigate user to servers page if a server map is empty
|
||||
return appPrivateRoutes.servers
|
||||
}
|
||||
eugene
commented
This transformation can be avoided if you change This transformation can be avoided if you change `serverMap` to be an array.
m
commented
https://git.nostrdev.com/sigit/sigit.io/pulls/278#issuecomment-3772
|
||||
|
||||
getRelayInfo(relays)
|
||||
|
||||
const relayMap = getRelayMapFromNDKRelayList(ndkRelayList)
|
||||
|
||||
if (authState.loggedIn && !compareObjects(relaysState?.map, relayMap)) {
|
||||
dispatch(setRelayMapAction(relayMap))
|
||||
if (authState.loggedIn) {
|
||||
if (!compareObjects(relaysState?.map, relayMap))
|
||||
dispatch(setRelayMapAction(relayMap))
|
||||
if (!compareObjects(store.getState().servers?.map, serverMap.map))
|
||||
dispatch(setServerMapAction(serverMap.map))
|
||||
}
|
||||
|
||||
return appPrivateRoutes.homePage
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
findMetadata,
|
||||
getNDKRelayList,
|
||||
fetchEvent,
|
||||
getRelayInfo,
|
||||
authState,
|
||||
relaysState
|
||||
authState.loggedIn,
|
||||
findMetadata,
|
||||
relaysState?.map
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -26,6 +26,7 @@ import {
|
||||
} from '../store/actions'
|
||||
import { Keys } from '../store/auth/types'
|
||||
import {
|
||||
BlossomVersion,
|
||||
isSigitNotification,
|
||||
Meta,
|
||||
SigitNotification,
|
||||
@ -119,7 +120,7 @@ export const useNDK = () => {
|
||||
return {
|
||||
sigits: {},
|
||||
processedGiftWraps: [],
|
||||
blossomUrls: [],
|
||||
blossomVersions: [],
|
||||
keyPair: {
|
||||
private: bytesToHex(secret),
|
||||
public: pubKey
|
||||
@ -142,6 +143,7 @@ export const useNDK = () => {
|
||||
|
||||
// Parse the decrypted content
|
||||
const parsedContent = await parseJson<{
|
||||
blossomVersions: BlossomVersion[]
|
||||
blossomUrls: string[]
|
||||
keyPair: Keys
|
||||
}>(decrypted).catch((err) => {
|
||||
@ -159,14 +161,23 @@ export const useNDK = () => {
|
||||
// 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
|
||||
)
|
||||
|
||||
@ -177,7 +188,7 @@ export const useNDK = () => {
|
||||
|
||||
// Return the final user application data
|
||||
return {
|
||||
blossomUrls,
|
||||
blossomVersions,
|
||||
keyPair,
|
||||
sigits,
|
||||
processedGiftWraps
|
||||
@ -223,9 +234,9 @@ export const useNDK = () => {
|
||||
|
||||
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
|
||||
@ -240,18 +251,26 @@ export const useNDK = () => {
|
||||
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
|
||||
m marked this conversation as resolved
eugene
commented
Should it be added at the end of the array instead? It would make sense, going from the first version to the last version, i.e. 1, 2 .... n Should it be added at the end of the array instead? It would make sense, going from the first version to the last version, i.e. 1, 2 .... n
m
commented
This is how it was done so far, the latest version was on top I don't know the reasoning This is how it was done so far, the latest version was on top I don't know the reasoning
|
||||
blossomVersions.unshift({
|
||||
urls: newBlossomUrls
|
||||
})
|
||||
|
||||
// Keep only the 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('Error removing old file 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
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -261,7 +280,7 @@ export const useNDK = () => {
|
||||
.nip04Encrypt(
|
||||
usersPubkey,
|
||||
JSON.stringify({
|
||||
blossomUrls,
|
||||
blossomVersions: blossomVersions,
|
||||
keyPair: appData.keyPair
|
||||
})
|
||||
)
|
||||
@ -309,7 +328,7 @@ export const useNDK = () => {
|
||||
dispatch(
|
||||
updateUserAppDataAction({
|
||||
sigits,
|
||||
blossomUrls,
|
||||
blossomVersions: blossomVersions,
|
||||
processedGiftWraps: [...appData.processedGiftWraps],
|
||||
keyPair: {
|
||||
...appData.keyPair
|
||||
@ -394,7 +413,7 @@ export const useNDK = () => {
|
||||
|
||||
try {
|
||||
meta = await fetchMetaFromFileStorage(
|
||||
notification.metaUrl,
|
||||
notification.metaUrls,
|
||||
encryptionKey
|
||||
)
|
||||
} catch (error) {
|
||||
|
@ -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,
|
||||
|
@ -18,7 +18,8 @@ store.subscribe(
|
||||
saveState({
|
||||
auth: store.getState().auth,
|
||||
user: store.getState().user,
|
||||
relays: store.getState().relays
|
||||
relays: store.getState().relays,
|
||||
servers: store.getState().servers
|
||||
})
|
||||
}, 1000)
|
||||
)
|
||||
|
@ -47,7 +47,8 @@ import {
|
||||
uploadToFileStorage,
|
||||
DEFAULT_TOOLBOX,
|
||||
settleAllFullfilfedPromises,
|
||||
uploadMetaToFileStorage
|
||||
uploadMetaToFileStorage,
|
||||
isValidNip05
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||
@ -251,8 +252,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)
|
||||
@ -729,10 +729,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`, {
|
||||
@ -791,14 +791,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
|
||||
}
|
||||
|
||||
@ -861,15 +861,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
|
||||
|
||||
@ -907,11 +907,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
|
||||
})
|
||||
|
||||
@ -944,7 +944,7 @@ export const CreatePage = () => {
|
||||
const createSignature = await generateCreateSignature(
|
||||
markConfig,
|
||||
fileHashes,
|
||||
''
|
||||
[]
|
||||
)
|
||||
if (!createSignature) return
|
||||
|
||||
|
@ -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 />
|
||||
|
@ -27,6 +27,7 @@ import {
|
||||
compareObjects,
|
||||
getRelayMapFromNDKRelayList,
|
||||
hexToNpub,
|
||||
isValidRelayUri,
|
||||
publishRelayMap,
|
||||
shorten,
|
||||
timeout
|
||||
@ -195,12 +196,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'
|
||||
|
264
src/pages/settings/servers/index.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
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, useNDKContext } 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>({})
|
||||
|
||||
const { ndk, fetchEvent, publish } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
fetchFileServers()
|
||||
})
|
||||
|
||||
const fetchFileServers = async () => {
|
||||
if (usersPubkey) {
|
||||
const servers = await getFileServerMap(usersPubkey, fetchEvent)
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
You should use either the async await syntax or promise chaining, not both of them at the same time. It's probably best to refactor to: You should use either the async await syntax or promise chaining, not both of them at the same time.
It's probably best to refactor to: `const servers = await getFileServerMap(usersPubkey, fetch event)`
m
commented
ah very strange, it was on accident ah very strange, it was on accident
|
||||
|
||||
if (servers.map) {
|
||||
if (Object.keys(servers.map).length === 0) {
|
||||
serverRequirementWarning()
|
||||
}
|
||||
|
||||
setBlossomServersMap(servers.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
y
commented
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)
|
||||
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)
|
||||
eugene
commented
Similar to my point elsewhere in the PR, I don't think we should be storing blossoms in a map. It should be an array instead. Similar to my point elsewhere in the PR, I don't think we should be storing blossoms in a map. It should be an array instead.
m
commented
https://git.nostrdev.com/sigit/sigit.io/pulls/278#issuecomment-3772
|
||||
}
|
||||
|
||||
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, ndk, publish)
|
||||
.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) => {
|
||||
axios
|
||||
.get(serverURL)
|
||||
.then((res) => {
|
||||
if (res && res.data?.toLowerCase().includes('blossom server')) {
|
||||
resolve(true)
|
||||
} else {
|
||||
resolve(false)
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
I don't think you need to reject the promise in this scenario. You can return a resolved promise with either I don't think you need to reject the promise in this scenario. You can return a resolved promise with either `true` (if you received the expected response) or `false`, and you won't need to do rely on catching the error in the client code to make a business logic decision.
m
commented
It makes very much sense, another "will fix it later" which never happened... It makes very much sense, another "will fix it later" which never happened...
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error validating file server.', err)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
111
src/pages/settings/servers/style.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -47,7 +47,7 @@ import {
|
||||
} from '../../utils/file.ts'
|
||||
import { generateTimestamp } from '../../utils/opentimestamps.ts'
|
||||
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
|
||||
import { useNDK } from '../../hooks/useNDK.ts'
|
||||
import { useNDK } from '../../hooks'
|
||||
import { getLastSignersSig } from '../../utils/sign.ts'
|
||||
|
||||
export const SignPage = () => {
|
||||
@ -90,6 +90,7 @@ export const SignPage = () => {
|
||||
}
|
||||
|
||||
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
|
||||
const [noFiles, setNoFiles] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
@ -287,26 +288,54 @@ 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) => {
|
||||
if (!zipUrls || zipUrls.length === 0) {
|
||||
toast.warning('No zip files found in the zipUrls')
|
||||
setIsLoading(false)
|
||||
setNoFiles(true)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* We start iterating through all URLs and fetch the zip. If zip is unreachable,
|
||||
eugene
commented
Does it mean that if a user has 3 blossom servrs, there will be 2 requests? Of these two, the second request will over-write the result of the first one? If you do need to iterate, you can probably use Does it mean that if a user has 3 blossom servrs, there will be 2 requests?
Of these two, the second request will over-write the result of the first one?
If you do need to iterate, you can probably use `.map` to handle promises and then set the value once they have been awaited, to make the code more declarative.
m
commented
It will take the first one which is valid, and it will only send more requests if the hash of the first one fails or so, I did iteration to be sequential on purpose, for that reason It will take the first one which is valid, and it will only send more requests if the hash of the first one fails or so, I did iteration to be sequential on purpose, for that reason
b
commented
rather than comment here, can we provide a comment in the code? rather than comment here, can we provide a comment in the code?
eugene
commented
Ok, thanks. What is the reason to add A bit of a nitpick, but I would also suggest using the Ok, thanks.
What is the reason to add `break` at the end of the last loop? Isn't the loop going to finish naturally because you increment `i`?
A bit of a nitpick, but I would also suggest using the `for ... of` loop over the 'vanilla' JS loop, because it is a little bit more declarative and still gives you the same sequential processing option and breaking out of the loop once you get the result.
|
||||
* or it fails the hash check, we skip to the next one. Iteration will stop
|
||||
* on the first successful zip, so that's why we do it sequentially.
|
||||
*/
|
||||
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()
|
||||
@ -479,6 +508,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
|
||||
|
||||
@ -660,9 +693,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)
|
||||
@ -704,7 +737,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(() => {
|
||||
@ -859,6 +895,7 @@ export const SignPage = () => {
|
||||
handleEncryptedExport={handleEncryptedExport}
|
||||
otherUserMarks={otherUserMarks}
|
||||
meta={meta}
|
||||
noFiles={noFiles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -63,6 +63,12 @@ import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx'
|
||||
|
||||
interface PdfViewProps {
|
||||
files: CurrentUserFile[]
|
||||
/**
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
The same question as before - do we need The same question as before - do we need `noFiles`, and if yes, can you some comment as to why.
|
||||
* Currently, loading spinner is present if `files` array is of length 0,
|
||||
* Which means if no files are found, loading spinner will be spinning indefinitely
|
||||
* For that reason `noFiles` is introduced to set the loading off when fetching is finished.
|
||||
*/
|
||||
noFiles?: boolean
|
||||
currentFile: CurrentUserFile | null
|
||||
parsedSignatureEvents: {
|
||||
[signer: `npub1${string}`]: DocSignatureEvent
|
||||
@ -71,6 +77,7 @@ interface PdfViewProps {
|
||||
|
||||
const SlimPdfView = ({
|
||||
files,
|
||||
noFiles,
|
||||
currentFile,
|
||||
parsedSignatureEvents
|
||||
}: PdfViewProps) => {
|
||||
@ -163,6 +170,8 @@ const SlimPdfView = ({
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
) : noFiles ? (
|
||||
''
|
||||
) : (
|
||||
<LoadingSpinner variant="small" />
|
||||
)}
|
||||
@ -218,7 +227,7 @@ export const VerifyPage = () => {
|
||||
|
||||
const {
|
||||
submittedBy,
|
||||
zipUrl,
|
||||
zipUrls,
|
||||
encryptionKey,
|
||||
signers,
|
||||
viewers,
|
||||
@ -378,7 +387,7 @@ export const VerifyPage = () => {
|
||||
const users = Array.from(userSet)
|
||||
const promises = users.map((user) =>
|
||||
sendNotification(npubToHex(user)!, {
|
||||
metaUrl,
|
||||
metaUrls: metaUrl,
|
||||
keys: meta.keys!
|
||||
})
|
||||
)
|
||||
@ -405,35 +414,64 @@ 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 a 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!)
|
||||
if (!zipUrls || zipUrls.length === 0) {
|
||||
toast.warning('No zip files found in the zipUrls')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
for (let i = 0; i < zipUrls.length; i++) {
|
||||
const zipUrl = zipUrls[i]
|
||||
const isLastZipUrl = i === zipUrls.length - 1
|
||||
|
||||
if (arrayBuffer) {
|
||||
try {
|
||||
// Fetch zip data
|
||||
const res = await axios.get(zipUrl, {
|
||||
responseType: '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)
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
Can you specify why it needs to break out of the loop if it's the last zip url? Can you specify why it needs to break out of the loop if it's the last zip url?
m
commented
I added a comment I added a comment
|
||||
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
|
||||
// If it's the last zip url, and still no `zip` found, break out of loop,
|
||||
// it means no files were successfully fetched or all files failed the validation (hash check).
|
||||
break
|
||||
}
|
||||
|
||||
const files: { [fileName: string]: SigitFile } = {}
|
||||
const fileHashes: { [key: string]: string | null } = {}
|
||||
@ -441,47 +479,43 @@ 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,
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
You don't need this if statement. You don't need this if statement. `getHash` returns a string or null, so you can directly assign `hash` instead of re-assigning `null`.
|
||||
fileName,
|
||||
entryFileName,
|
||||
'arraybuffer'
|
||||
)
|
||||
|
||||
if (arrayBuffer) {
|
||||
files[fileName] = await convertToSigitFile(
|
||||
arrayBuffer,
|
||||
fileName!
|
||||
if (entryArrayBuffer) {
|
||||
files[entryFileName] = await convertToSigitFile(
|
||||
entryArrayBuffer,
|
||||
entryFileName
|
||||
)
|
||||
const hash = await getHash(arrayBuffer)
|
||||
|
||||
if (hash) {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = hash
|
||||
}
|
||||
fileHashes[entryFileName.replace(/^files\//, '')] =
|
||||
await getHash(entryArrayBuffer)
|
||||
} 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
|
||||
@ -773,7 +807,15 @@ export const VerifyPage = () => {
|
||||
currentFile={currentFile}
|
||||
files={getCurrentUserFiles(files, currentFileHashes, fileHashes)}
|
||||
parsedSignatureEvents={parsedSignatureEvents}
|
||||
noFiles={!zipUrls || zipUrls.length === 0}
|
||||
/>
|
||||
{!zipUrls || zipUrls.length === 0 ? (
|
||||
<Typography textAlign="center">
|
||||
We were not able to retrieve the files.
|
||||
</Typography>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</StickySideColumns>
|
||||
)}
|
||||
</Container>
|
||||
|
@ -8,6 +8,7 @@ export const appPrivateRoutes = {
|
||||
profileSettings: '/settings/profile/:npub',
|
||||
cacheSettings: '/settings/cache',
|
||||
relays: '/settings/relays',
|
||||
servers: '/settings/servers',
|
||||
nostrLogin: '/settings/nostrLogin'
|
||||
}
|
||||
|
||||
|
@ -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 />
|
||||
|
54
src/services/config/index.ts
Normal 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()
|
@ -11,6 +11,9 @@ export const SET_USER_PROFILE = 'SET_USER_PROFILE'
|
||||
|
||||
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'
|
||||
|
@ -9,11 +9,14 @@ import { RelaysDispatchTypes, RelaysState } from './relays/types'
|
||||
import UserAppDataReducer from './userAppData/reducer'
|
||||
import { UserAppDataDispatchTypes } from './userAppData/types'
|
||||
import { UserDispatchTypes, UserState } from './user/types'
|
||||
import { ServersDispatchTypes, FileServersState } from './servers/types.ts'
|
||||
import serversReducer from './servers/reducer'
|
||||
|
||||
export interface State {
|
||||
auth: AuthState
|
||||
user: UserState
|
||||
relays: RelaysState
|
||||
servers: FileServersState
|
||||
userAppData?: UserAppData
|
||||
}
|
||||
|
||||
@ -21,12 +24,14 @@ type AppActions =
|
||||
| AuthDispatchTypes
|
||||
| UserDispatchTypes
|
||||
| RelaysDispatchTypes
|
||||
| ServersDispatchTypes
|
||||
| UserAppDataDispatchTypes
|
||||
|
||||
export const appReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
user: userReducer,
|
||||
relays: relaysReducer,
|
||||
servers: serversReducer,
|
||||
userAppData: UserAppDataReducer
|
||||
})
|
||||
|
||||
|
12
src/store/servers/action.ts
Normal 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
|
||||
})
|
28
src/store/servers/reducer.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { ServersDispatchTypes, FileServersState } from './types'
|
||||
|
||||
const initialState: FileServersState = {
|
||||
map: undefined,
|
||||
mapUpdated: undefined
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: ServersDispatchTypes
|
||||
): FileServersState => {
|
||||
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
|
22
src/store/servers/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState } from '../actions'
|
||||
import { ServerMap } from '../../types'
|
||||
|
||||
export type FileServersState = {
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
I think I think `Servers` is too ambiguous to use as a type. It should either be `FileServer` or `BlossomServer`. `FileServer` is a better option if we want to allow for a possibility that in the future there will be other compatible standards other than Blossom.
|
||||
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
|
@ -5,7 +5,7 @@ import { UserAppDataDispatchTypes } from './types'
|
||||
const initialState: UserAppData = {
|
||||
sigits: {},
|
||||
processedGiftWraps: [],
|
||||
blossomUrls: []
|
||||
blossomVersions: []
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
|
3
src/types/config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface ILocalConfig {
|
||||
SIGIT_BLOSSOM: string
|
||||
}
|
@ -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[]
|
||||
m marked this conversation as resolved
eugene
commented
Ah, I get it - we now have an archive of blossom urls. ~~Why not keep the existing property, `blossomUrls`? It looks like the contect is exactly the same, an array of strings.~~
Ah, I get it - we now have an archive of blossom urls.
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
@ -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 {
|
||||
|
18
src/types/file-server.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* We are using a map instead of an array because the future plan is to have
|
||||
* read and write file servers, so this model is prepared for that nostr feature.
|
||||
*/
|
||||
export type FileServerMap = {
|
||||
[key: string]: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileServerPutResponse {
|
||||
sha256: string
|
||||
size: number
|
||||
uploaded: number
|
||||
type: string
|
||||
url: string
|
||||
}
|
@ -4,3 +4,5 @@ export * from './nostr'
|
||||
export * from './relay'
|
||||
export * from './zip'
|
||||
export * from './event'
|
||||
export * from './server'
|
||||
export * from './file-server.ts'
|
||||
|
6
src/types/server.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type ServerMap = {
|
||||
[key: string]: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
}
|
@ -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
|
||||
m marked this conversation as resolved
eugene
commented
Should it be configurable as well? Should it be configurable as well?
m
commented
For now I don't think so, what do @b thinks? For now I don't think so, what do @b thinks?
b
commented
it can be a seperate issue / PR if we decide to do that (probably we'd combine it with read-only servers) it can be a seperate issue / PR if we decide to do that (probably we'd combine it with read-only servers)
|
||||
|
||||
// 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([
|
||||
|
114
src/utils/file-servers.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { FileServerMap } from '../types'
|
||||
import { NostrController } from '../controllers'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
import { unixNow } from './nostr.ts'
|
||||
import { Filter, UnsignedEvent, kinds } from 'nostr-tools'
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKSubscriptionOptions
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
|
||||
/**
|
||||
* Fetches the relays to get preferred file servers for the given npub
|
||||
* @param npub hex pubkey
|
||||
* @param fetchEvent provided by Nostr dev kit
|
||||
*/
|
||||
const getFileServerMap = async (
|
||||
npub: string,
|
||||
fetchEvent: (
|
||||
filter: NDKFilter,
|
||||
opts?: NDKSubscriptionOptions | undefined
|
||||
) => Promise<NDKEvent | null>
|
||||
): Promise<{ map: FileServerMap; mapUpdated?: number }> => {
|
||||
try {
|
||||
// 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]
|
||||
}
|
||||
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
It would be better to wrap the whole body of the function in the It would be better to wrap the whole body of the function in the `try catch` block, rather than just catch an error in this one instance.
|
||||
const event = await fetchEvent(eventFilter)
|
||||
|
||||
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() })
|
||||
}
|
||||
} catch (err) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a preferred file servers list for the given npub
|
||||
* @param serverMap list of preferred servers
|
||||
* @param npub hex pubkey
|
||||
* @param ndk provided by Nostr dev kit
|
||||
* @param publish provided by Nostr dev kit
|
||||
*/
|
||||
const publishFileServer = async (
|
||||
serverMap: FileServerMap,
|
||||
npub: string,
|
||||
ndk: NDK,
|
||||
publish: (event: NDKEvent) => Promise<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)
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishResult = await publish(ndkEvent)
|
||||
|
||||
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 }
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import axios from 'axios'
|
||||
import { truncate } from 'lodash'
|
||||
import {
|
||||
@ -15,13 +15,18 @@ import {
|
||||
nip44,
|
||||
verifyEvent
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { NIP05_REGEX } from '../constants'
|
||||
import store from '../store/store'
|
||||
import { Meta, SignedEvent } from '../types'
|
||||
import {
|
||||
Meta,
|
||||
SignedEvent,
|
||||
FileServerPutResponse,
|
||||
BlossomVersion
|
||||
} from '../types'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
import { getHash } from './hash'
|
||||
import { parseJson, removeLeadingSlash } from './string'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
/**
|
||||
* Generates a `d` tag for userAppData
|
||||
@ -344,7 +349,7 @@ export 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.
|
||||
@ -355,6 +360,11 @@ export 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,
|
||||
@ -401,33 +411,44 @@ export 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
|
||||
}
|
||||
})
|
||||
// Upload the file to the file storage services using Axios
|
||||
const responses = await Promise.all(
|
||||
preferredServers.map((preferredServer) => {
|
||||
return axios.put<FileServerPutResponse>(
|
||||
m marked this conversation as resolved
Outdated
eugene
commented
You could use You could use `.map` to collect promises in `Promise.all` to make the code more declarative.
|
||||
`${preferredServer}/upload`,
|
||||
file,
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Return the URL of the uploaded file
|
||||
return response.data.url as string
|
||||
// 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.
|
||||
*/
|
||||
export const getUserAppDataFromBlossom = async (
|
||||
url: string,
|
||||
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) => {
|
||||
@ -439,8 +460,13 @@ export const getUserAppDataFromBlossom = async (
|
||||
})
|
||||
.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) {
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
Can you please re-phrease this comment to make it clearer? Or better yet, add a comment to the overall function.