diff --git a/package.json b/package.json new file mode 100644 index 0000000..216ee40 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "archive-fs-neo-org", + "version": "0.0.1", + "private": true, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-brands-svg-icons": "^6.4.0", + "@fortawesome/free-regular-svg-icons": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@fortawesome/react-fontawesome": "^0.2.0", + "@types/react": "^18.2.41", + "@types/react-dom": "^18.2.17", + "bulma": "^0.9.4", + "react": "^17.0.2", + "react-bulma-components": "^4.1.0", + "react-dom": "^17.0.2", + "react-router-dom": "^6.10.0" + }, + "scripts": { + "start": "REACT_APP_VERSION=$(make version) GENERATE_SOURCEMAP=false react-scripts start", + "build": "GENERATE_SOURCEMAP=false BUILD_PATH='./archive.fs.neo.org' react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "devDependencies": { + "dotenv": "^16.0.3", + "react-scripts": "^5.0.1", + "typescript": "^4.9.5" + } +} diff --git a/public/img/close.svg b/public/img/close.svg new file mode 100644 index 0000000..73396e3 --- /dev/null +++ b/public/img/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/cover.png b/public/img/cover.png new file mode 100644 index 0000000..44df980 Binary files /dev/null and b/public/img/cover.png differ diff --git a/public/img/favicon.ico b/public/img/favicon.ico new file mode 100644 index 0000000..5a1c553 Binary files /dev/null and b/public/img/favicon.ico differ diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 0000000..2081f97 --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,103 @@ + + + + + + image/svg+xml + + NeoFS + + + + + + + + NeoFS + + + + + + + + + + + + diff --git a/public/img/socials/github.svg b/public/img/socials/github.svg new file mode 100644 index 0000000..aa05db9 --- /dev/null +++ b/public/img/socials/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/socials/medium.svg b/public/img/socials/medium.svg new file mode 100644 index 0000000..08a9433 --- /dev/null +++ b/public/img/socials/medium.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/img/socials/neo.svg b/public/img/socials/neo.svg new file mode 100644 index 0000000..75e2d8b --- /dev/null +++ b/public/img/socials/neo.svg @@ -0,0 +1,64 @@ + + + + + + image/svg+xml + + Asset 9 + + + + + + + + Asset 9 + + + diff --git a/public/img/socials/neo_spcc.svg b/public/img/socials/neo_spcc.svg new file mode 100644 index 0000000..d0b04c6 --- /dev/null +++ b/public/img/socials/neo_spcc.svg @@ -0,0 +1,108 @@ + + + + + + image/svg+xml + + Asset 9 + + + + + + + + Asset 9 + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/socials/twitter.svg b/public/img/socials/twitter.svg new file mode 100644 index 0000000..1970575 --- /dev/null +++ b/public/img/socials/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/socials/youtube.svg b/public/img/socials/youtube.svg new file mode 100644 index 0000000..6c30aa1 --- /dev/null +++ b/public/img/socials/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3ad41fb --- /dev/null +++ b/public/index.html @@ -0,0 +1,28 @@ + + + + + Archive.NeoFS + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..e03dfd5 --- /dev/null +++ b/src/App.css @@ -0,0 +1,381 @@ +body { + margin: 0; + padding: 0; + color: #111827; + background: #fff; + font-family: 'Poppins', sans-serif; + min-width: 300px; + -webkit-tap-highlight-color: rgba(255, 255, 255, 0); +} + +a { + text-decoration: none; +} + +:focus { + outline-color: #02af92; +} + +.input:active, +.input:focus, +.is-active.input, +.is-active.textarea, +.is-focused.input, +.is-focused.textarea, +.select select.is-active, +.select select.is-focused, +.select select:active, +.select select:focus, +.textarea:active, +.textarea:focus { + border-color: #02af92; +} + +.input[disabled] { + border-color: #dbdbdb; +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.select select[disabled] { + border-color: #dbdbdb !important; +} + +.select:not(.is-multiple):not(.is-loading)::after { + border-color: #02af92; +} + +.select_block { + display: flex; + align-items: center; + flex-direction: column; +} + +.select_block select { + min-width: 300px; +} + +.inputs_block { + display: flex; + justify-content: center; +} + +.inputs_block input { + width: 145px; + margin: 0 5px; +} + +progress { + border-radius: 4px !important; + text-align: center; + margin: 15px auto 10px !important; + max-width: 90%; + height: 10px !important; +} + +progress::-webkit-progress-value { + background: #02af92 !important; +} + +.navbar-item, +.navbar-link { + color: #ffffff80 !important; + background: transparent !important; +} + +.notification a:not(.button):not(.dropdown-item) { + text-decoration: none; +} + +.notification { + padding: 1.25rem 1.5rem 1.25rem 1.5rem; +} + +a.navbar-item:hover, +div.navbar-item:hover { + cursor: pointer; + color: #fff !important; +} + +.navbar, +.navbar-menu { + background: #29363b; +} + +.navbar-burger { + color: #ffffff; +} + +.tooltip { + position: absolute; + min-width: 70px; + background: #29363b; + text-align: center; + padding: 4px 8px; + font-size: 12px; + border-radius: 4px; + color: #fff; + top: -80%; +} + +.tooltip:after { + position: absolute; + border: solid transparent; + content: ""; + height: 0; + width: 0; + top: 100%; + right: 50%; + border-width: 6px; + margin: -2px -6px; + border-top-color: #29363b; +} + +.socials { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; +} + +.socials a { + line-height: 0; + margin: 0 10px; +} + +.social_pipe { + border-right: 2px solid rgb(0, 0, 0); + padding-right: 10px; + display: flex; +} + +.button { + outline: none; + box-shadow: unset !important; +} + +.button.is-primary, +.notification.is-primary { + color: #fff; + background: #02af92; + border-color: #02af92; +} + +.notification.is-primary { + padding: 0.5rem 1rem; + margin-bottom: 0.5rem; +} + +.notification>.delete { + right: 1rem; + top: 0.65rem; +} + +.file.is-boxed .file-cta { + border-style: dashed; + border-width: 2px; +} + +.file-cta, +.file-name { + white-space: normal; + text-align: center; +} + +.file.is-boxed .file-icon { + height: 3.5em; +} + +.label { + font-weight: 400; +} + +.button.is-focused, +.button:focus { + border-color: inherit; +} + +.footer .subtitle { + line-height: 1.5; +} + +code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: #eff1f2; + color: inherit; + border-radius: 6px; +} + +.content pre { + color: inherit; + border-radius: 4px; +} + +/* modal */ +.modal { + position: fixed; + z-index: 102; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); +} + +.modal_close_panel { + position: fixed; + z-index: 102; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; +} + +.modal_content { + position: absolute; + background: #fff; + border-radius: 4px; + z-index: 103; + padding: 1.25rem 1.5rem 1.25rem 1.5rem; + min-width: 300px; + max-width: 350px; + margin: 10px; +} + +.modal_scroll { + overflow-y: auto; + width: 100%; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.modal_scroll .modal_content { + position: relative; +} + +.modal_close { + padding: 5px; + position: absolute; + top: 0; + right: 0; +} + +.modal_close img { + cursor: pointer; +} + +.modal_loader { + display: flex; + margin: 5px auto 15px; + -webkit-animation: pulse 1.5s infinite linear; + animation: pulse 1.5s infinite linear; +} + +@media (prefers-color-scheme: dark) { + html { + background: #2d333b; + } + + body { + color: #adbac7; + background: #22272d; + } + + .navbar-menu, + .footer, + .modal_content { + background: #2d333b; + } + + .subtitle, + .navbar-item, + .navbar-link, + .label { + color: #adbac7 !important; + } + + .notification code, + .notification pre { + background: #343942; + } + + .notification.is-primary .subtitle { + color: #fff !important; + } + + .navbar, + .file-cta:hover { + background: #2d333b !important; + } + + .notification { + background: #22272d; + border: 2px solid #343942; + } + + .file-cta { + background: #22272d; + border-color: #343942; + color: #adbac7 !important; + } + + .input, + .select select, + .textarea { + color: #adbac7; + background-color: #22272d; + border-color: #343942; + outline: none !important; + } + + .select:not(.is-multiple):not(.is-loading)::after { + border-color: #adbac7; + } + + .socials a { + filter: invert(1); + } + + .social_pipe { + border-color: #fff; + } +} + +@media (min-width: 1025px) { + .navbar-menu { + margin-right: 6rem; + } + + .navbar-brand { + margin-left: 6rem; + } +} + +@media (max-width: 500px) { + .title { + font-size: 20px; + } + + .section { + padding: 1.5rem 1rem; + } + + .notification { + padding: 1rem; + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..0b9e0bb --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,241 @@ +import React, { useState } from 'react'; +import { Route, Routes } from "react-router-dom"; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { + Navbar, + Heading, + Footer, + Button, + Progress, +} from 'react-bulma-components'; +import Home from './Home.tsx'; +import NotFound from './NotFound.tsx'; +import 'bulma/css/bulma.min.css'; +import './App.css'; + +import { + faSpinner, + faDownload, +} from '@fortawesome/free-solid-svg-icons'; + +library.add( + faDownload, + faSpinner, +); + +interface NetItem { + title: string + containerId: string +} + +interface Modal { + current: string | null + params: any +} + +export const App = () => { + const [archive, setArchive] = useState([]); + const [nets] = useState([{ + title: 'Mainnet', + containerId: '3RCdP3ZubyKyo8qFeo7EJPryidTZaGCMdUjqFJaaEKBV', + }, { + title: 'Testnet', + containerId: 'A8nGtDemWrm2SjfcGAG6wvrxmXwqc5fwr8ezNDm6FraT', + }, { + title: 'NeoFS Mainnet', + containerId: 'BP71MqY7nJhpuHfdQU3infRSjMgVmSFFt9GfG2GGMZJj', + }, { + title: 'NeoFS Testnet', + containerId: '98xz5YeanzxRCpH6EfUhECVm2MynGYchDN4naJViHT9M', + }]); + const [progress, setProgress] = useState(0); + const [isLoading, setLoading] = useState(false); + const [modal, setModal] = useState({ + current: null, + params: '', + }); + + const onModal = (current: string | null = null, params: any = null) => { + setModal({ current, params }); + }; + + const saveAsAccFile = (formData: { network: number; spanStart: number; spanEnd: number }) => { + if (!archive.length) return; + + const blockCount = new Int32Array([archive.length]); + + const chunks: ArrayBuffer[] = [blockCount.buffer]; + archive.forEach((block: Uint8Array) => { + const blockSize = new Uint32Array([block.byteLength]); + chunks.push(blockSize.buffer); + chunks.push(new Uint8Array(block)); + }); + + const mergedData = new Blob(chunks, { type: "application/octet-stream" }); + + const url = URL.createObjectURL(mergedData); + const a = document.createElement("a"); + a.href = url; + a.download = `snapshot_${nets[formData.network].title}_${formData.spanStart}-${formData.spanEnd}.acc`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + <> + {(modal.current === 'success' || modal.current === 'failed') && ( +
+
onModal()} + /> +
+
onModal()} + > + close +
+ {modal.current === 'success' ? 'Success' : 'Failed'} +

{modal.params}

+
+
+ )} + {modal.current === 'loading' && ( +
+
+ Loading + {`Snapshot: ${modal.params.spanStart} - ${modal.params.spanEnd} (${nets[modal.params.network].title})`} + + {`Progress: ${progress}%`} + + + +
+
+ )} + + + + logo + + + +
+ + } + /> + } + /> + +
+ + + ); +} diff --git a/src/Home.tsx b/src/Home.tsx new file mode 100644 index 0000000..1c477b0 --- /dev/null +++ b/src/Home.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { + Content, + Container, + Form, + Section, + Heading, + Tile, + Notification, + Button, +} from 'react-bulma-components'; +import api from './api.ts'; + +interface NetItem { + title: string + containerId: string +} + +interface FormData { + spanStart: number | '' + spanEnd: number | '' + network: number +} + +const Home = ({ + onModal, + nets, + setArchive, + setProgress, + isLoading, + setLoading, +}) => { + const [formData, setFormData] = useState({ + spanStart: '', + spanEnd: '', + network: 0, + }); + + const roundNumber = (num: number): number => { + const rounded = num.toFixed(2); + return parseFloat(rounded) % 1 === 0 ? parseInt(rounded) : parseFloat(rounded); + }; + + const fetchBlocksInRange = async () => { + if (formData.spanStart === '' || formData.spanStart < 0 || formData.spanEnd === '' || formData.spanEnd < 0) return onModal('failed', 'Insert correct data'); + + onModal('loading', formData); + setLoading(true); + const currentNet: NetItem = nets[formData.network]; + let archiveData: Uint8Array[] = []; + + for (let block = formData.spanStart; block <= formData.spanEnd; block++) { + const objectId: Uint8Array | string = await fetchBlock(currentNet, block); + if (typeof objectId === 'string') { + onModal('failed', objectId); + return + } + archiveData.push(objectId); + setProgress(roundNumber(((block - formData.spanStart + 1) / (formData.spanEnd - formData.spanStart + 1)) * 100)); + } + + setArchive(archiveData); + setLoading(false); + }; + + const fetchBlock = async (currentNet: NetItem, blockNumber: number): Promise => { + try { + const searchResponse: any = await api('POST', `/objects/${currentNet.containerId}/search?walletConnect=false&offset=0&limit=1`, { + filters: [{ + "key": "Block", + "match": "MatchStringEqual", + "value": blockNumber.toString(), + }], + }); + + const objectId = searchResponse.objects[0]?.address.objectId; + if (!objectId) { + return `Error: Object not found for block #${blockNumber}`; + } + + const blockResponse = await api('GET', `/objects/${currentNet.containerId}/by_id/${objectId}?walletConnect=false`); + return blockResponse as Uint8Array; + } catch (err: any) { + return `Error loading block #${blockNumber}: ${err.message}`; + } + }; + + return ( + +
+ + + + Archive.NeoFS – Download Blockchain Data Snapshot + +

Easily download an offline package of blockchain data up to a specific block height.

+

Manual steps:

+
    +
  • Choose start and end snapshot option for the data range;
  • +
  • Select the desired network;
  • +
  • Click the Download button;
  • +
  • Wait for the full blockchain data to prepared;
  • +
  • Download the .acc file to your device. 🚀
  • +
+
+
+
+
+ + + + + + { + if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) { + setFormData({ ...formData, spanStart: e.target.value >= 0 ? e.target.value : '' }); + } + }} + disabled={isLoading} + /> + + + { + if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) { + setFormData({ ...formData, spanEnd: e.target.value >= 0 ? e.target.value : '' }); + } + }} + disabled={isLoading} + /> + + + + + setFormData({ ...formData, network: Number(e.target.value) })} + value={formData.network} + disabled={isLoading} + > + + + + + + + + + + + + + +
+
+ ); +} + +export default Home; diff --git a/src/NotFound.tsx b/src/NotFound.tsx new file mode 100644 index 0000000..b1f393d --- /dev/null +++ b/src/NotFound.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { + Container, + Section, + Heading, + Tile, + Notification, + Button, +} from 'react-bulma-components'; + +const NotFound = () => { + return ( + +
+ + + + 404 Not Found + Page not found + + + + + + +
+
+ ); +}; + +export default NotFound; diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..f697779 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,48 @@ +const server = 'https://rest.fs.neo.org/v1'; + +type Methods = "GET" | "POST"; + +async function serverRequest(method: Methods, url: string, params: object, headers: any) { + const json: any = { + method, + headers, + } + + if (json['headers']['Content-Type']) { + json['body'] = params; + } else if (Object.keys(params).length > 0) { + json['body'] = JSON.stringify(params); + json['headers']['Content-Type'] = 'application/json'; + } + + let activeUrl: string = url; + if (server) { + activeUrl = `${server}${url}`; + } + + return fetch(activeUrl, json).catch((error: any) => { + console.log(error); + }); +} + +export default function api(method: Methods, url: string, params: object = {}, headers: object = {}) { + return new Promise((resolve, reject) => { + serverRequest(method, url, params, headers).then(async (response: any) => { + if (response && response.status === 204) { + resolve({ status: 'success' }); + } else { + let res: any = response; + if (response.status === 200 && url.indexOf('by_id') !== -1) { + res = await response.arrayBuffer(); + resolve(res); + } else if (response.status === 200) { + res = await response.json(); + resolve(res); + } else { + res = await response.json(); + reject(res); + } + } + }); + }); +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..6302304 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { App } from './App.tsx'; +import { BrowserRouter } from "react-router-dom"; + +ReactDOM.render( + + + + + , + document.getElementById('root') +);