diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ee0d4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log +build +.dockerignore +**/.git +**/.DS_Store +**/node_modules diff --git a/.github/logo.svg b/.github/logo.svg new file mode 100644 index 0000000..b4da076 --- /dev/null +++ b/.github/logo.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9dc1868 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,67 @@ +name: Build + +on: + pull_request: + branches: + - master + types: [opened, synchronize] + paths-ignore: + - '**/*.md' + push: + # Build for the master branch. + branches: + - master + release: + types: + - published + workflow_dispatch: + inputs: + ref: + description: 'Ref to build [default: latest master; examples: v0.4.0, 9595da7d83efc330ca0bc94bef482e4edfbcf8fd]' + required: false + default: '' + deploy: + description: 'Deploy to production [default: false; examples: true, false]' + required: false + default: 'false' + +jobs: + build_release: + name: Build and deploy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref }} + # Allows to fetch all history for all branches and tags. Need this for proper versioning. + fetch-depth: 0 + + - name: Build + run: make release + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: release + path: ./archive.fs.neo.org-*.tar.gz + if-no-files-found: error + + - name: Attach binary to the release as an asset + if: ${{ github.event_name == 'release' }} + run: gh release upload ${{ github.event.release.tag_name }} ./archive.fs.neo.org-*.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to NeoFS + if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy == 'true') }} + uses: nspcc-dev/gh-push-to-neofs@master + with: + NEOFS_WALLET: ${{ secrets.NEOFS_WALLET }} + NEOFS_WALLET_PASSWORD: ${{ secrets.NEOFS_WALLET_PASSWORD }} + NEOFS_NETWORK_DOMAIN: ${{ vars.NEOFS_NETWORK_DOMAIN }} + NEOFS_HTTP_GATE: ${{ vars.NEOFS_HTTP_GATE }} + STORE_OBJECTS_CID: ${{ vars.STORE_OBJECTS_CID }} + PATH_TO_FILES_DIR: archive.fs.neo.org + STRIP_PREFIX: true + REPLACE_CONTAINER_CONTENTS: true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c31d95e --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +#!/usr/bin/make -f + +SHELL = bash + +VERSION ?= "$(shell git describe --tags --match "v*" --abbrev=8 2>/dev/null | sed -r 's,^v([0-9]+\.[0-9]+)\.([0-9]+)(-.*)?$$,\1 \2 \3,' | while read mm patch suffix; do if [ -z "$$suffix" ]; then echo $$mm.$$patch; else patch=`expr $$patch + 1`; echo $$mm.$${patch}-pre$$suffix; fi; done)" +SITE_DIR ?= archive.fs.neo.org +RELEASE_DIR ?= $(SITE_DIR)-$(VERSION) +RELEASE_PATH ?= $(SITE_DIR)-$(VERSION).tar.gz +CURRENT_UID ?= $(shell id -u $$USER) + +PORT = 3000 + +$(SITE_DIR): + docker run \ + -v $$(pwd)/src:/usr/src/app/src \ + -v $$(pwd)/public:/usr/src/app/public \ + -v $$(pwd)/package.json:/usr/src/app/package.json \ + -v $$(pwd)/$(SITE_DIR):/usr/src/app/$(SITE_DIR) \ + -e CURRENT_UID=$(CURRENT_UID) \ + -w /usr/src/app node:14-alpine \ + sh -c 'npm install && REACT_APP_VERSION=$(VERSION) npm run build && chown -R $$CURRENT_UID: $(SITE_DIR)' + +start: + docker run \ + -p $(PORT):3000 \ + -v `pwd`:/usr/src/app \ + -w /usr/src/app node:14-alpine \ + sh -c 'npm install --silent && npm run build && npm install -g serve && serve -s $(SITE_DIR) -p 3000' + +release: $(SITE_DIR) + cp $(SITE_DIR)/index.html $(SITE_DIR)/about + @ln -sf $(SITE_DIR) $(RELEASE_DIR) + @tar cfvhz $(RELEASE_PATH) $(RELEASE_DIR) + +clean: + @echo "Cleaning up ..." + @rm -rf $(SITE_DIR) $(RELEASE_DIR) $(RELEASE_PATH) + +release_name: + @echo $(RELEASE_PATH) + +version: + @echo $(VERSION) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ff664a --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +

+NeoFS +

+

+ NeoFS is a decentralized distributed object storage integrated with the Neo Blockchain. +

+ +--- +![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/nspcc-dev/archive-fs-neo-org?sort=semver) +![License](https://img.shields.io/github/license/nspcc-dev/archive-fs-neo-org.svg?style=popout) + +# Overview + +Archive.NeoFS – Offline Synchronization Package. Download an offline block dump up to a certain block height. This web application is built on the React framework. + +# Requirements + +- docker +- make +- node (`14+`) + +# Make instructions + +* Compile the build using `make` (will be generated in `archive-fs-neo-org` dir) +* Start app using `make start PORT=3000` (PORT=3000 by default) +* Clean up cache directories using `make clean` +* Get release directory with tar.gz using `make release` + +# License + +- [GNU General Public License v3.0](LICENSE) diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2ab157 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "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", + "base-58": "^0.0.1", + "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..2dc9624 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..89f78c0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,28 @@ + + + + + Archive.NeoFS + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/src/About.tsx b/src/About.tsx new file mode 100644 index 0000000..3923cc3 --- /dev/null +++ b/src/About.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { + Content, + Container, + Section, + Heading, + Tile, + Notification, +} from 'react-bulma-components'; + +const About = () => { + return ( + +
+ + + + About Service + +

Archive.NeoFS is a web application that allows users to create blockchain archives of any span (from block 0 to the current block or a custom range) directly in the browser. It operates fully client-side, leveraging standard NeoFS REST gateway APIs and in-browser streaming techniques to efficiently fetch and store blocks without requiring additional backend processing.

+

The service supports four networks: mainnet, testnet, NeoFS mainnet, and NeoFS testnet. It interacts with the NeoFS REST gateway to retrieve blockchain data stored in NeoFS objects and assembles them into a structured archive format (.acc), that is compatible with both C# Neo node and NeoGo.

+

Frontend part first determines the latest available block in the selected network using the getblockcount method in the RPC request. Each block is stored as a separate object with a unique Object ID (OID), while index files contain references to batches of 128,000 blocks, mapping block indices to their corresponding OIDs. It then, using NeoFS REST gateway, the program first retrieves index files using SEARCH, then extracts object IDs then fetches these objects (containing blocks) via GET NeoFS request.

+

The process runs entirely in the browser using the showSaveFilePicker API for file handling and the WritableStream API for efficient in-browser streaming. Downloaded blocks are written directly into an archive .acc file, ensuring minimal memory overhead. However, due to API limitations, this feature is only supported in modern browsers: Chrome 86+ (recommended).

+
+
+
+
+
+
+ ); +} + +export default About; diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..ea0fe5d --- /dev/null +++ b/src/App.css @@ -0,0 +1,386 @@ +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; +} + +#about a { + color: #02af92 +} + +: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; + margin-bottom: 0 !important; +} + +.select_block select { + min-width: 300px; +} + +.inputs_block { + display: flex; + justify-content: center; +} + +.inputs_block input { + width: 145px; + margin: 0 5px; +} + +progress { + border-radius: 6px !important; + text-align: center; + margin: 10px auto !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..321d014 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,275 @@ +import React, { useState } from 'react'; +import { Link, Route, Routes } from "react-router-dom"; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { + Navbar, + Heading, + Footer, + Progress, + Button, +} from 'react-bulma-components'; +import Home from './Home.tsx'; +import About from './About.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 + rpc: string + maxBlock: number +} + +interface Modal { + current: string | null + params: any + btn: string | null | Function +} + +export const App = () => { + const [nets, setNets] = useState([{ + title: 'Mainnet', + containerId: '3RCdP3ZubyKyo8qFeo7EJPryidTZaGCMdUjqFJaaEKBV', + rpc: 'https://rpc10.n3.nspcc.ru:10331', + maxBlock: 0, + }, { + title: 'Testnet', + containerId: 'A8nGtDemWrm2SjfcGAG6wvrxmXwqc5fwr8ezNDm6FraT', + rpc: 'https://rpc.t5.n3.nspcc.ru:20331', + maxBlock: 0, + }, { + title: 'NeoFS Mainnet', + containerId: 'BP71MqY7nJhpuHfdQU3infRSjMgVmSFFt9GfG2GGMZJj', + rpc: 'https://rpc.morph.fs.neo.org', + maxBlock: 0, + }, { + title: 'NeoFS Testnet', + containerId: '98xz5YeanzxRCpH6EfUhECVm2MynGYchDN4naJViHT9M', + rpc: 'https://rpc1.morph.t5.fs.neo.org', + maxBlock: 0, + }]); + const [currentDownloadedBlock, setCurrentDownloadedBlock] = useState(0); + const [menuActive, setMenuActive] = useState(false); + const [isLoading, setLoading] = useState(false); + const [modal, setModal] = useState({ + current: null, + params: '', + btn: null, + }); + + const onModal = (current: string | null = null, params: any = null, btn: string | null = null) => { + setModal({ current, params, btn }); + }; + + const roundNumber = (num: number): number => { + const rounded = num.toFixed(2); + return parseFloat(rounded) % 1 === 0 ? parseInt(rounded) : parseFloat(rounded); + }; + + return ( + <> + {(modal.current === 'success' || modal.current === 'failed') && ( +
+
onModal()} + /> +
+
onModal()} + > + close +
+ {modal.current === 'success' ? 'Success' : 'Failed'} +

{modal.params}

+ {typeof modal.btn === 'function' && ( + + )} + {modal.btn === 'about' && ( + + )} +
+
+ )} + {modal.current === 'loading' && ( +
+
{} : () => { + onModal(); + setCurrentDownloadedBlock(0); + }} + /> +
+ {`Snapshot`} + {`${modal.params.spanStart} - ${modal.params.spanEnd} (${nets[modal.params.network].title})`} + {currentDownloadedBlock / (modal.params.spanEnd - modal.params.spanStart + 1) === 1 ? 'Success!' : 'Writing'} + + {`${currentDownloadedBlock} / ${modal.params.spanEnd - modal.params.spanStart + 1} (${roundNumber((currentDownloadedBlock / (modal.params.spanEnd - modal.params.spanStart + 1)) * 100)}%)`} +
+
+ )} + + + + logo + + setMenuActive(!menuActive)} + /> + + + + setMenuActive(false)} + > + Download + + setMenuActive(false)} + > + About + + + + +
+ + } + /> + } + /> + } + /> + +
+ + + ); +} diff --git a/src/Home.tsx b/src/Home.tsx new file mode 100644 index 0000000..42e021f --- /dev/null +++ b/src/Home.tsx @@ -0,0 +1,276 @@ +import React, { useState, useEffect } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Content, + Container, + Form, + Section, + Heading, + Tile, + Tag, + Notification, + Button, +} from 'react-bulma-components'; +import api from './api.ts'; + +const base58 = require('base-58'); + +interface NetItem { + title: string + containerId: string +} + +interface FormData { + spanStart: number | '' + spanEnd: number | '' + network: number +} + +const Home = ({ + onModal, + nets, + setNets, + setCurrentDownloadedBlock, + isLoading, + setLoading, +}) => { + const [formData, setFormData] = useState({ + spanStart: 0, + spanEnd: '', + network: 0, + }); + + let fileHandle: FileSystemFileHandle | null = null; + + useEffect(() => { + if (nets[formData.network].maxBlock === 0) { + api('POST', nets[formData.network].rpc, { + "jsonrpc": "2.0", + "id": 1, + "method": "getblockcount", + "params": [] + }).then((res: any) => { + const netsTemp = [...nets]; + netsTemp[formData.network].maxBlock = Math.floor(res.result / 128000) * 128000; + setNets(netsTemp); + + setFormData({ ...formData, spanEnd: nets[formData.network].maxBlock }); + }).catch(() => { + onModal('failed', 'Failed to fetch the last available block'); + }); + } else { + if (formData.spanEnd > nets[formData.network].maxBlock) return setFormData({ ...formData, spanEnd: nets[formData.network].maxBlock }); + } + },[formData.network]); // eslint-disable-line react-hooks/exhaustive-deps + + const fetchBlocksInRange = async (retryIndex: number | null = null) => { + if (formData.spanStart === '' || formData.spanEnd === '' || formData.spanEnd < 0) return onModal('failed', 'Insert correct data'); + if (formData.spanStart < 0 || formData.spanEnd < 0 || formData.spanStart > nets[formData.network].maxBlock || formData.spanEnd > nets[formData.network].maxBlock) return onModal('failed', 'Insert correct borders'); + + + if (retryIndex === null) { + setLoading(true); + } + const currentNet: NetItem = nets[formData.network]; + + try { + if (retryIndex === null) { + fileHandle = await window.showSaveFilePicker({ + suggestedName: `chain.${formData.spanStart}.acc`, + types: [{ accept: { 'application/octet-stream': ['.acc'] } }], + }); + } + onModal('loading', formData); + + const writableStream = await fileHandle?.createWritable(retryIndex === null ? {} : { keepExistingData: true }); + + const blockCount = formData.spanEnd - formData.spanStart + 1; + if (retryIndex === null) { + await writableStream?.write(new Int32Array([blockCount]).buffer); + } else { + const offset: any = (await fileHandle?.getFile())?.size; + writableStream?.seek(offset) + } + + const indexFileStart = Math.floor(formData.spanStart / 128000); + const indexFileCount = Math.ceil((formData.spanEnd - formData.spanStart) / 128000) + indexFileStart; + for (let indexFile = indexFileStart; indexFile <= indexFileCount; indexFile += 1) { + + const indexData: Uint8Array | string = await fetchIndexFile(currentNet, indexFile); + if (typeof indexData === 'string') { + await writableStream?.close(); + onModal('failed', indexData, (retryIndexTemp: number) => fetchBlocksInRange(+formData.spanStart + retryIndexTemp)); + return + } + + const uint8Data = new Uint8Array(indexData); + const objectsData: string[] = []; + for (let i = 0; i < uint8Data.length; i += 32) { + const chunk = uint8Data.slice(i, i + 32); + const encoded = base58.encode(chunk); + objectsData.push(encoded); + } + + const startBlock = retryIndex !== null ? retryIndex : formData.spanStart; + for (let i = startBlock - (128000 * indexFile); i <= objectsData.length; i += 1) { + if (blockCount <= (indexFile * 128000 + i - formData.spanStart)) { + await writableStream?.close(); + return + } + + const objectData: Uint8Array | string = await fetchBlock(currentNet, objectsData[i]); + if (typeof objectData === 'string') { + await writableStream?.close(); + onModal('failed', objectData, (currentDownloadedBlockTemp: number) => fetchBlocksInRange(+formData.spanStart + currentDownloadedBlockTemp)); + return + } + + const blockSize = new Uint32Array([objectData.byteLength]); + await writableStream?.write(blockSize.buffer); + await writableStream?.write(new Uint8Array(objectData)); + setCurrentDownloadedBlock((indexFile * 128000 + i) - formData.spanStart + 1); + } + } + } catch (error: any) { + if (error.message.indexOf('showSaveFilePicker is not a function') !== -1) { + onModal('failed', 'Your current browser does not support this site\'s functionality. For the best experience, please use Chrome 86+ (recommended).', 'about'); + } else { + onModal('failed', error.message || 'Error occurred during block fetching.', (retryIndexTemp: number) => fetchBlocksInRange(+formData.spanStart + retryIndexTemp)); + } + } finally { + setLoading(false); + } + }; + + const fetchIndexFile = async (currentNet: NetItem, indexNumber: number): Promise => { + try { + const searchResponse: any = await api('POST', `/objects/${currentNet.containerId}/search?walletConnect=false&offset=0&limit=1`, { + filters: [{ + "key": "Index", + "match": "MatchStringEqual", + "value": indexNumber.toString(), + }], + }); + + const objectId = searchResponse.objects[0]?.address.objectId; + if (!objectId) { + return `Error occurred during index fetching #${indexNumber}`; + } + + const indexResponse = await api('GET', `/objects/${currentNet.containerId}/by_id/${objectId}?walletConnect=false`); + return indexResponse as Uint8Array; + } catch (err: any) { + return `Error occurred during index fetching #${indexNumber}: ${err.message}`; + } + }; + + const fetchBlock = async (currentNet: NetItem, objectId: string): Promise => { + try { + const blockResponse = await api('GET', `/objects/${currentNet.containerId}/by_id/${objectId}?walletConnect=false`); + return blockResponse as Uint8Array; + } catch (err: any) { + return `Error occurred during object fetching ${objectId}: ${err.message}`; + } + }; + + return ( + +
+ + + + Archive.NeoFS – Offline Synchronization Package + +

Easily download an offline package of blocks 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 .acc file to download to your device. 🚀
  • +
+

For the best experience, please use Chrome 86+.

+
+
+
+
+ + + + Prepare snapshot + + + { + if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) { + setFormData({ ...formData, spanStart: e.target.value !== '' && e.target.value >= 0 ? Number(e.target.value) : '' }); + } + }} + disabled={isLoading} + /> + + + { + if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) { + setFormData({ ...formData, spanEnd: e.target.value !== '' && e.target.value >= 0 ? Number(e.target.value) : '' }); + } + }} + disabled={isLoading} + /> + + + + + setFormData({ ...formData, network: Number(e.target.value) })} + value={formData.network} + disabled={isLoading} + > + + + + + + + + + {`the latest available block is ${nets[formData.network].maxBlock ? nets[formData.network].maxBlock : '-'}`} + + + + + + + +
+
+ ); +} + +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..24577ef --- /dev/null +++ b/src/api.ts @@ -0,0 +1,50 @@ +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 (url.indexOf('https') === -1) { + activeUrl = `${server}${url}`; + } + + return fetch(activeUrl, json).catch((error: any) => 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 { + try { + res = await response.json(); + reject(res); + } catch (err) { + reject(response); + } + } + } + }); + }); +} 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') +);