├── .babelrc
├── .github
├── dependabot.yml
└── workflows
│ ├── deploy-v1.yml
│ ├── deploy.yml
│ └── pr.yml
├── .gitignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── package.json
├── public
├── assets
│ ├── favicon
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ └── favicon.ico
│ ├── skytransfer-promo.jpg
│ └── skytransfer.png
├── index.html
└── manifest.json
├── scripts
└── version-bump.sh
├── src
├── app.less
├── app.tsx
├── app
│ └── store.ts
├── components
│ ├── about
│ │ ├── about.css
│ │ └── about.tsx
│ ├── buckets
│ │ ├── buckets.css
│ │ └── buckets.tsx
│ ├── common
│ │ ├── __snapshots__
│ │ │ └── tabs-cards.test.tsx.snap
│ │ ├── bucket-information.tsx
│ │ ├── bucket-modal.tsx
│ │ ├── directory-tree-line
│ │ │ ├── directory-tree-line.css
│ │ │ └── directory-tree-line.tsx
│ │ ├── helpers.ts
│ │ ├── icons.css
│ │ ├── icons.tsx
│ │ ├── notification.css
│ │ ├── notification.tsx
│ │ ├── qr.tsx
│ │ ├── share-modal.tsx
│ │ ├── tabs-cards.test.tsx
│ │ └── tabs-cards.tsx
│ ├── filelist
│ │ └── file-list.tsx
│ ├── header
│ │ ├── header.css
│ │ └── header.tsx
│ ├── redirect-v1
│ │ └── redirect-v1.tsx
│ ├── support-us
│ │ ├── support-us.css
│ │ └── support-us.tsx
│ └── uploader
│ │ ├── activity-bar.tsx
│ │ ├── dragger-content.tsx
│ │ ├── uploader.css
│ │ └── uploader.tsx
├── config.ts
├── crypto
│ ├── chunk-resolver.ts
│ ├── crypto.ts
│ ├── json.ts
│ ├── xchacha20poly1305-decrypt.ts
│ └── xchacha20poly1305-encrypt.ts
├── features
│ ├── bucket
│ │ └── bucket-slice.ts
│ └── user
│ │ ├── user-slice.ts
│ │ └── user.tsx
├── images
│ ├── bucket.svg
│ ├── change-portal.svg
│ ├── homescreen.svg
│ ├── skytransfer-logo.svg
│ ├── smile.svg
│ └── support.svg
├── index.css
├── index.tsx
├── models
│ ├── encryption.ts
│ ├── file-tree.ts
│ ├── files
│ │ ├── bucket.ts
│ │ ├── encrypted-file.ts
│ │ └── file-data.ts
│ ├── session.ts
│ └── user.ts
├── portals.test.ts
├── portals.ts
├── react-app-env.d.ts
├── session
│ └── session-manager.ts
├── skynet
│ └── skynet.ts
├── utils
│ ├── utils.ts
│ └── walker.ts
├── version.ts
└── workers
│ └── worker.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/env",
4 | "@babel/react",
5 | "@babel/preset-typescript"
6 | ],
7 | "plugins": [
8 | "@babel/plugin-proposal-class-properties",
9 | "inline-react-svg"
10 | ]
11 | }
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm"
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-v1.yml:
--------------------------------------------------------------------------------
1 | name: build and deploy v1
2 |
3 | on:
4 | push:
5 | branches:
6 | - v1
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 | - uses: actions/setup-node@v2
15 | with:
16 | node-version: "16"
17 |
18 | - name: Install dependencies
19 | run: yarn
20 |
21 | - name: Build
22 | run: yarn build
23 | env:
24 | REACT_APP_GIT_SHA: ${{ github.sha }}
25 |
26 | - name: Test
27 | run: yarn test
28 |
29 | - name: "Deploy to Skynet"
30 | uses: SkynetLabs/deploy-to-skynet-action@v2
31 | with:
32 | upload-dir: build
33 | github-token: ${{ secrets.GITHUB_TOKEN }}
34 | registry-seed: ${{ github.event_name == 'push' && secrets.REGISTRY_SEED_V1 || '' }}
35 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: build and deploy
2 |
3 | on:
4 | push:
5 | branches: [main]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v2
13 | - uses: actions/setup-node@v2
14 | with:
15 | node-version: "16"
16 |
17 | - uses: actions-ecosystem/action-get-latest-tag@v1
18 | id: get-latest-tag
19 |
20 | - uses: actions-ecosystem/action-bump-semver@v1
21 | id: bump-semver
22 | with:
23 | current_version: ${{ steps.get-latest-tag.outputs.tag }}
24 | level: patch
25 |
26 | - uses: actions-ecosystem/action-push-tag@v1
27 | with:
28 | tag: ${{ steps.bump-semver.outputs.new_version }}
29 |
30 | - name: Bump version before build
31 | run: ./scripts/version-bump.sh ${{ steps.bump-semver.outputs.new_version }}
32 |
33 | - name: Install dependencies
34 | run: yarn
35 |
36 | - name: Build
37 | run: yarn build
38 | env:
39 | REACT_APP_GIT_SHA: ${{ github.sha }}
40 | NODE_ENV: production
41 |
42 | - name: Test
43 | run: yarn test
44 |
45 | - name: "Deploy to Skynet"
46 | uses: SkynetLabs/deploy-to-skynet-action@v2
47 | with:
48 | upload-dir: build
49 | github-token: ${{ secrets.GITHUB_TOKEN }}
50 | registry-seed: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && secrets.REGISTRY_SEED || '' }}
51 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: build and deploy
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 | - uses: actions/setup-node@v2
15 | with:
16 | node-version: "16"
17 |
18 | - uses: actions-ecosystem/action-get-latest-tag@v1
19 | id: get-latest-tag
20 |
21 | - uses: actions-ecosystem/action-bump-semver@v1
22 | id: bump-semver
23 | with:
24 | current_version: ${{ steps.get-latest-tag.outputs.tag }}
25 | level: patch
26 |
27 | - name: Bump version before build
28 | run: ./scripts/version-bump.sh ${{ steps.bump-semver.outputs.new_version }}
29 |
30 | - name: Install dependencies
31 | run: yarn
32 |
33 | - name: Build
34 | run: yarn build
35 | env:
36 | REACT_APP_GIT_SHA: ${{ github.sha }}
37 | NODE_ENV: production
38 |
39 | - name: Test
40 | run: yarn test
41 |
42 | - name: "Deploy to Skynet"
43 | uses: SkynetLabs/deploy-to-skynet-action@v2
44 | with:
45 | upload-dir: build
46 | github-token: ${{ secrets.GITHUB_TOKEN }}
47 | registry-seed: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && secrets.REGISTRY_SEED || '' }}
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | dist-ssr
5 | *.local
6 | .eslintcache
7 | build/
8 | .idea/
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSpacing": true,
4 | "endOfLine": "lf",
5 | "htmlWhitespaceSensitivity": "css",
6 | "jsxBracketSameLine": false,
7 | "jsxSingleQuote": false,
8 | "semi": true,
9 | "singleQuote": true,
10 | "tabWidth": 2
11 | }
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Kamil Molendys & Michał Sokołowski
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## [SkyTransfer](http://skytransfer.hns.siasky.net/)
2 |
3 | 
4 |
5 | SkyTransfer is an open source decentralized file sharing platform. Do you have difficulty sharing files with others or
6 | between different devices? Are you writing an email and you need to attach files? Are you struggling with a ton of
7 | pictures to share? Try doing it using SkyTransfer. Use the minimal but powerful uploader (file picker or drag&drop) for
8 | uploading and sharing one or multiple files with a single link or QR code.
9 |
10 | Uploaded files are encrypted using the [sodium crypto library](https://github.com/jedisct1/libsodium.js) compiled to
11 | WebAssembly and pure JavaScript: no one can access them without your permission. Only by sharing the bucket link, other
12 | people can download and decrypt the content you uploaded. In addition, by sharing a draft, people can continue uploading
13 | more data into the same bucket. Be careful when you share a draft!
14 |
15 | SkyTransfer supports uploading entire directories.
16 |
17 | Last but not least, using MySky you can access buckets, SkyTransfer's most advanced feature. Buckets are like folders in
18 | which files are stored. Before a collection of files can be uploaded, a bucket must first be created.
19 |
20 | SkyTransfer is still in development. Please report any bug or new idea by opening an issue on github and keep in mind
21 | that the encryption process has not yet been audited.
22 |
23 | ### Add to Skynet Homescreen
24 |
25 | [](https://homescreen.hns.siasky.net/#/skylink/AQAJGCmM4njSUoFx-YNm64Zgea8QYRo-kHHf3Vht04mYBQ)
26 |
27 | ### Credits
28 |
29 | Some of the icons we use are from [Flaticon](https://www.flaticon.com/) by [Freepik](https://www.freepik.com).
30 |
31 | ---
32 |
33 | ### Support Us
34 |
35 | We are constantly shipping new features. We don't want to steal your time but if you really want, you can support Us
36 | with a donation. It's your way of returning the love we invested in developing SkyTransfer. We would appreciate it very
37 | much.
38 |
39 | #### Siacoin wallet address
40 |
41 | a34ac5a2aa1d5174b1a9289584ab4cdb5d2f99fa24de4a86d592fb02b2f81b754ff97af0e6e4
42 |
43 | #### Buy Us a coffee
44 |
45 |
46 |
47 | ---
48 |
49 | ### Special mentions
50 |
51 | A big thank-you goes to [JetBrains](https://jb.gg/OpenSource) that supports our open source project with its great products!
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "skytransfer",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "start": "webpack serve",
6 | "build": "webpack",
7 | "test": "SKIP_PREFLIGHT_CHECK=true react-scripts test",
8 | "eject": "react-scripts eject",
9 | "fmt": "prettier --config .prettierrc.json --write src/**/*.ts{,x}",
10 | "lint": "tsc --noEmit && eslint src/**/*.ts{,x} --fix"
11 | },
12 | "dependencies": {
13 | "@ant-design/icons": "^4.7.0",
14 | "@reduxjs/toolkit": "^1.8.1",
15 | "@types/uuid": "^8.3.4",
16 | "antd": "^4.19.5",
17 | "axios": "^0.26.1",
18 | "axios-retry": "^3.1.9",
19 | "babel-plugin-inline-react-svg": "^2.0.1",
20 | "crypto-js": "^4.0.0",
21 | "react": "^18.0.0",
22 | "react-app-polyfill": "^3.0.0",
23 | "react-bootstrap": "^2.2.3",
24 | "react-colorful": "^5.2.2",
25 | "react-device-detect": "^2.0.1",
26 | "react-dom": "^18.0.0",
27 | "react-qr-svg": "^2.4.0",
28 | "react-redux": "^7.2.8",
29 | "react-router-dom": "^6.3.0",
30 | "react-scripts": "^5.0.0",
31 | "skynet-js": "4.0.27-beta",
32 | "typescript": "^4.6.3",
33 | "web-vitals": "^2.1.4"
34 | },
35 | "devDependencies": {
36 | "@babel/core": "^7.17.9",
37 | "@babel/preset-env": "^7.16.11",
38 | "@babel/preset-react": "^7.16.7",
39 | "@babel/preset-typescript": "^7.16.7",
40 | "@skynethub/userprofile-library": "0.1.18-beta",
41 | "@testing-library/jest-dom": "^5.16.4",
42 | "@testing-library/react": "^13.0.0",
43 | "@types/jest": "^27.4.1",
44 | "@types/libsodium-wrappers": "^0.7.9",
45 | "@types/react": "^17.0.43",
46 | "@types/react-dom": "^17.0.14",
47 | "@vitejs/plugin-react-refresh": "^1.3.1",
48 | "babel-loader": "^8.2.4",
49 | "comlink": "^4.3.1",
50 | "copy-webpack-plugin": "^10.2.4",
51 | "css-loader": "^6.7.1",
52 | "file-loader": "^6.2.0",
53 | "html-webpack-plugin": "^5.3.2",
54 | "i": "^0.3.7",
55 | "less": "^4.1.1",
56 | "less-loader": "^10.0.1",
57 | "libsodium": "^0.7.10",
58 | "libsodium-wrappers": "^0.7.10",
59 | "npm": "^8.6.0",
60 | "path": "^0.12.7",
61 | "prettier": "2.6.2",
62 | "react-svg-loader": "^3.0.3",
63 | "source-map-loader": "^3.0.1",
64 | "style-loader": "^3.3.0",
65 | "ts-loader": "^9.2.8",
66 | "tus-js-client": "^2.3.1",
67 | "webpack": "^5.72.0",
68 | "webpack-cli": "^4.9.2",
69 | "webpack-dev-server": "^4.8.1"
70 | },
71 | "eslintConfig": {
72 | "extends": [
73 | "react-app",
74 | "react-app/jest"
75 | ]
76 | },
77 | "browserslist": {
78 | "production": [
79 | ">0.2%",
80 | "not dead",
81 | "not op_mini all"
82 | ],
83 | "development": [
84 | "last 1 chrome version",
85 | "last 1 firefox version",
86 | "last 1 safari version"
87 | ]
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/public/assets/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/assets/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/assets/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/assets/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/assets/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/assets/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/assets/skytransfer-promo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/skytransfer-promo.jpg
--------------------------------------------------------------------------------
/public/assets/skytransfer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/public/assets/skytransfer.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SkyTransfer
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | You need to enable JavaScript to run this app.
17 |
18 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "SkyTransfer",
3 | "description": "Decentralized open source file sharing platform.",
4 | "icons": [
5 | {
6 | "src": "/assets/favicon/apple-touch-icon.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/assets/favicon/favicon-32x32.png",
12 | "sizes": "32x32",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "/assets/favicon/favicon-16x16.png",
17 | "sizes": "16x16",
18 | "type": "image/png"
19 | },
20 | {
21 | "src": "/assets/favicon/android-chrome-192x192.png",
22 | "sizes": "192x192",
23 | "type": "image/png"
24 | },
25 | {
26 | "src": "/assets/favicon/android-chrome-512x512.png",
27 | "sizes": "512x512",
28 | "type": "image/png"
29 | }
30 | ],
31 | "theme_color": "white",
32 | "skylink": "sia://AQAJGCmM4njSUoFx-YNm64Zgea8QYRo-kHHf3Vht04mYBQ"
33 | }
34 |
--------------------------------------------------------------------------------
/scripts/version-bump.sh:
--------------------------------------------------------------------------------
1 | echo "export const APP_VERSION = '$1';" > src/version.ts
--------------------------------------------------------------------------------
/src/app.less:
--------------------------------------------------------------------------------
1 | @import '~antd/dist/antd.less';
2 | @import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
3 |
4 | html,
5 | body {
6 | font-family: 'Lato', sans-serif;
7 | background: #f3f3f3;
8 | }
9 |
10 | .ant-layout {
11 | background: #f3f3f3;
12 | }
13 |
14 | .ant-layout-footer {
15 | background: none;
16 | }
17 |
18 | .App {
19 | text-align: center;
20 | font-family: 'Lato', sans-serif;
21 | }
22 |
23 | /* .layout {
24 | background-color:transparent;
25 | } */
26 |
27 | .container {
28 | margin: 20px auto;
29 | width: 1080px;
30 | }
31 |
32 | .page {
33 | background-color: #fff;
34 | padding: 25px;
35 | max-width: 960px;
36 | margin: 20px auto;
37 | font-size: 15px;
38 | box-shadow: rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px;
39 | }
40 |
41 | @media only screen and (max-width: 1200px) {
42 | .container {
43 | margin: 20px 5%;
44 | width: 90%;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import './app.less';
2 |
3 | import { HashRouter as Router, Route, Routes } from 'react-router-dom';
4 |
5 | import Uploader from './components/uploader/uploader';
6 | import FileList from './components/filelist/file-list';
7 |
8 | import { Divider, Layout } from 'antd';
9 | import AppHeader from './components/header/header';
10 | import Buckets from './components/buckets/buckets';
11 | import About from './components/about/about';
12 | import SupportUs from './components/support-us/support-us';
13 | import { HomescreenIcon } from './components/common/icons';
14 | import { HeaderNotification } from './components/common/notification';
15 | import RedirectV1 from './components/redirect-v1/redirect-v1';
16 |
17 | const { Content, Footer } = Layout;
18 |
19 | const App = () => {
20 | return (
21 |
22 |
23 |
26 |
31 | SkyTransfer v2
32 | {' '}
33 | is here but you can still{' '}
34 |
39 | access the previous version
40 |
41 | .
42 | >
43 | }
44 | />
45 |
46 |
47 |
48 |
52 |
53 |
54 | }
55 | />
56 |
60 |
61 |
62 | }
63 | />
64 |
68 |
69 |
70 | }
71 | />
72 |
76 |
77 |
78 | }
79 | />
80 |
84 |
85 |
86 | }
87 | />
88 |
92 |
93 |
94 | }
95 | />
96 |
97 |
98 |
132 |
133 |
134 | );
135 | };
136 |
137 | export default App;
138 |
--------------------------------------------------------------------------------
/src/app/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import bucketSlice from '../features/bucket/bucket-slice';
3 | import userSlice from '../features/user/user-slice';
4 |
5 | export default configureStore({
6 | reducer: {
7 | user: userSlice,
8 | bucket: bucketSlice,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/src/components/about/about.css:
--------------------------------------------------------------------------------
1 | .about .title {
2 | color: #20bf6b;
3 | font-weight: bold;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/about/about.tsx:
--------------------------------------------------------------------------------
1 | import { SkyTransferLogo } from '../common/icons';
2 | import './about.css';
3 |
4 | import { Divider, Typography } from 'antd';
5 |
6 | const { Title } = Typography;
7 |
8 | const About = () => {
9 | return (
10 |
11 |
12 | About us
13 |
14 |
15 |
16 |
17 |
18 |
19 | SkyTransfer is an open source
20 | decentralized file sharing platform. Do you have difficulty{' '}
21 | sharing files with others or{' '}
22 | between different devices ? Are you writing an email and
23 | you need to attach files ? Are you struggling with a ton
24 | of pictures to share ? Try doing it using SkyTransfer.
25 | Use the minimal but powerful uploader (file picker or drag&drop) for{' '}
26 | uploading and sharing one or multiple files with a{' '}
27 | single link or QR code .
28 |
29 |
30 | Uploaded files are{' '}
31 |
32 | encrypted using{' '}
33 |
38 | the sodium crypto library
39 |
40 | {' '}
41 | compiled to WebAssembly and pure JavaScript: no one can access them
42 | without your permission. Only by{' '}
43 | sharing the bucket link , other people can{' '}
44 | download and decrypt the content you uploaded. In
45 | addition, by sharing a draft , people can continue
46 | uploading more data into the same bucket. Be careful when you share a
47 | draft!
48 |
49 |
50 | SkyTransfer supports uploading entire directories .
51 |
52 |
53 | Last but not least, using MySky you can access buckets ,
54 | SkyTransfer's most advanced feature. Buckets are like folders in which
55 | files are stored. Before a collection of files can be uploaded, a bucket
56 | must first be created.
57 |
58 |
59 | SkyTransfer is still in development. Please report any bug or new idea{' '}
60 |
61 | by opening an issue on github
62 | {' '}
63 | and keep in mind that the encryption process has not yet been audited.
64 |
65 |
66 | );
67 | };
68 |
69 | export default About;
70 |
--------------------------------------------------------------------------------
/src/components/buckets/buckets.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/src/components/buckets/buckets.css
--------------------------------------------------------------------------------
/src/components/buckets/buckets.tsx:
--------------------------------------------------------------------------------
1 | import './buckets.css';
2 |
3 | import { useEffect, useState } from 'react';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { getAllUserDecryptedBuckets, getMySky } from '../../skynet/skynet';
7 |
8 | import {
9 | Button,
10 | Col,
11 | Divider,
12 | Drawer,
13 | List,
14 | message,
15 | Modal,
16 | Row,
17 | Typography,
18 | } from 'antd';
19 |
20 | import { genKeyPairAndSeed, MySky } from 'skynet-js';
21 | import { IBuckets, IReadWriteBucketInfo } from '../../models/files/bucket';
22 |
23 | import { User } from '../../features/user/user';
24 |
25 | import {
26 | deleteReadOnlyBucket,
27 | deleteReadWriteBucket,
28 | login,
29 | selectUser,
30 | } from '../../features/user/user-slice';
31 | import { useDispatch, useSelector } from 'react-redux';
32 | import { IUserState, UserStatus } from '../../models/user';
33 |
34 | import {
35 | DeleteOutlined,
36 | InboxOutlined,
37 | LoginOutlined,
38 | ProfileOutlined,
39 | } from '@ant-design/icons';
40 | import { BucketModal } from '../common/bucket-modal';
41 |
42 | import { v4 as uuid } from 'uuid';
43 | import { BucketIcon } from '../common/icons';
44 | import {
45 | IBucketState,
46 | selectBucket,
47 | setUserKeys,
48 | } from '../../features/bucket/bucket-slice';
49 |
50 | const { Title } = Typography;
51 |
52 | const generateNewBucketInfo = (): IReadWriteBucketInfo => {
53 | const tempBucketID = uuid();
54 |
55 | const bucketPrivateKey = genKeyPairAndSeed().privateKey;
56 | const bucketEncryptionKey = genKeyPairAndSeed().privateKey;
57 |
58 | return {
59 | bucketID: tempBucketID,
60 | privateKey: bucketPrivateKey,
61 | encryptionKey: bucketEncryptionKey,
62 | };
63 | };
64 |
65 | const Buckets = () => {
66 | const [isloading, setIsLoading] = useState(false);
67 | const [newBucketModalVisible, setNewBucketModalVisible] = useState(false);
68 | const [newBucketInfo, setNewBucketInfo] = useState(
69 | generateNewBucketInfo()
70 | );
71 |
72 | const [readOnlyDecryptedBuckets, setReadOnlyDecryptedBuckets] =
73 | useState({});
74 | const [readWriteDecryptedBuckets, setReadWriteDecryptedBuckets] =
75 | useState({});
76 |
77 | const userState: IUserState = useSelector(selectUser);
78 | const bucketState: IBucketState = useSelector(selectBucket);
79 |
80 | const dispatch = useDispatch();
81 | const navigate = useNavigate();
82 |
83 | const loadBuckets = async () => {
84 | setIsLoading(true);
85 | try {
86 | const mySky: MySky = await getMySky();
87 | const allDecryptedBuckets = await getAllUserDecryptedBuckets(
88 | mySky,
89 | userState.buckets
90 | );
91 |
92 | setReadOnlyDecryptedBuckets(allDecryptedBuckets.readOnly);
93 | setReadWriteDecryptedBuckets(allDecryptedBuckets.readWrite);
94 | } catch (error) {
95 | message.error(error.message);
96 | }
97 | setIsLoading(false);
98 | };
99 |
100 | useEffect(() => {
101 | loadBuckets();
102 | }, [userState.buckets]);
103 |
104 | const [visibleDrawer, setVisibleDrawer] = useState(false);
105 | const showDrawer = () => {
106 | setVisibleDrawer(true);
107 | };
108 |
109 | const closeDrawer = () => {
110 | setVisibleDrawer(false);
111 | };
112 |
113 | const openReadWriteBucket = (bucketID: string) => {
114 | if (bucketID in userState.buckets.readWrite) {
115 | const readWriteBucketInfo = userState.buckets.readWrite[bucketID];
116 |
117 | dispatch(
118 | setUserKeys({
119 | bucketPrivateKey: readWriteBucketInfo.privateKey,
120 | bucketEncryptionKey: readWriteBucketInfo.encryptionKey,
121 | })
122 | );
123 |
124 | navigate('/');
125 | }
126 | };
127 |
128 | const openReadOnlyBucket = (bucketID: string) => {
129 | if (bucketID in userState.buckets.readOnly) {
130 | const readOnlyBucketInfo = userState.buckets.readOnly[bucketID];
131 | navigate(
132 | `/v2/${readOnlyBucketInfo.publicKey}/${readOnlyBucketInfo.encryptionKey}`
133 | );
134 | }
135 | };
136 |
137 | const deleteReadWriteBucketUsingModal = async (bucketID: string) => {
138 | const mySky = await getMySky();
139 | dispatch(deleteReadWriteBucket(mySky, bucketID));
140 | };
141 |
142 | const deleteReadOnlyBucketUsingModal = async (bucketID: string) => {
143 | const mySky = await getMySky();
144 | dispatch(deleteReadOnlyBucket(mySky, bucketID));
145 | };
146 |
147 | const newDraftConfirmModal = (onNewDraftClick: () => void) => {
148 | Modal.confirm({
149 | title: 'Are you sure?',
150 | icon: ,
151 | content: `By creating a new anonymous bucket, all files you've uploaded will be lost if you don't have the bucket link. Make sure you've saved the anonymous bucket link before continuing.`,
152 | okText: 'New bucket',
153 | cancelText: 'Cancel',
154 | onOk: onNewDraftClick,
155 | });
156 | };
157 |
158 | const deleteBucketConfirmModal = (onDeleteBucketClick: () => void) => {
159 | Modal.confirm({
160 | title: 'Are you sure?',
161 | icon: ,
162 | content: `Once you unpin a bucket, you will lose access to all the files it contains (unless you have the link saved somewhere).`,
163 | okText: 'Unpin',
164 | cancelText: 'Cancel',
165 | onOk: onDeleteBucketClick,
166 | });
167 | };
168 |
169 | const userNotLogged = (): boolean => {
170 | return userState.status === UserStatus.NotLogged;
171 | };
172 |
173 | const userLoading = (): boolean => {
174 | return userState.status === UserStatus.Loading;
175 | };
176 |
177 | return (
178 |
182 | {userNotLogged() || userLoading() ? (
183 | <>
184 |
185 | Buckets
186 |
187 |
188 |
189 |
190 | Buckets are SkyTransfer's most advanced feature. Thanks to them you
191 | can manage your content like never before.
192 |
193 |
194 |
195 | Sign in with MySky, access your buckets and unclock the power of
196 | SkyTransfer.
197 |
198 |
dispatch(login())}
202 | type="primary"
203 | icon={ }
204 | size="middle"
205 | >
206 | Sign in with MySky
207 |
208 |
209 |
Continue as anonymous user and create an anonymous bucket.
210 |
}
212 | size="middle"
213 | type="ghost"
214 | onClick={() =>
215 | newDraftConfirmModal(() => {
216 | setNewBucketInfo(generateNewBucketInfo());
217 | setNewBucketModalVisible(true);
218 | })
219 | }
220 | >
221 | Create anonymous bucket
222 |
223 | >
224 | ) : (
225 | <>
226 |
227 |
228 | Buckets
229 |
230 |
231 | }
233 | type="ghost"
234 | size="middle"
235 | onClick={showDrawer}
236 | >
237 | Manage account
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 | Welcome to the buckets section where you can access previously
246 | created buckets.
247 |
248 |
249 | Buckets are like folders in which files are stored. Before files
250 | can be uploaded, a bucket must first be created.
251 |
252 |
253 |
}
255 | size="middle"
256 | type="primary"
257 | onClick={() => setNewBucketModalVisible(true)}
258 | >
259 | Create bucket
260 |
261 |
262 |
Read write buckets
263 |
(
270 | <>
271 |
272 |
276 | openReadWriteBucket(item.uuid)}
281 | key={`open-${item.uuid}`}
282 | >
283 | open
284 |
285 | {
290 | deleteBucketConfirmModal(() =>
291 | deleteReadWriteBucketUsingModal(item.uuid)
292 | );
293 | }}
294 | key={`delete-${item.uuid}`}
295 | >
296 | unpin
297 |
298 |
299 | >
300 | )}
301 | />
302 |
303 | Read only buckets
304 | (
311 | <>
312 |
313 |
317 | openReadOnlyBucket(item.uuid)}
322 | key={`open-${item.uuid}`}
323 | >
324 | open
325 |
326 | {
331 | deleteBucketConfirmModal(() => {
332 | deleteReadOnlyBucketUsingModal(item.uuid);
333 | });
334 | }}
335 | key={`delete-${item.uuid}`}
336 | >
337 | unpin
338 |
339 |
340 | >
341 | )}
342 | />
343 | >
344 | )}
345 | setNewBucketModalVisible(false)}
349 | isLoggedUser={userState.status === UserStatus.Logged}
350 | modalTitle="Create new bucket"
351 | onDone={(bucketInfo) => {
352 | navigate('/');
353 | }}
354 | onError={(e) => {
355 | console.error(e);
356 | setNewBucketModalVisible(false);
357 | }}
358 | />
359 |
365 |
366 |
367 |
368 | );
369 | };
370 |
371 | export default Buckets;
372 |
--------------------------------------------------------------------------------
/src/components/common/__snapshots__/tabs-cards.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`TabsCards renders correctly when there are no values 1`] = `
4 |
5 |
8 |
12 |
24 |
27 |
38 |
43 |
52 |
55 |
56 |
57 |
58 |
59 |
60 |
67 |
68 |
69 | `;
70 |
71 | exports[`TabsCards renders correctly with values 1`] = `
72 |
73 |
76 |
80 |
83 |
87 |
90 |
98 | Some name 1
99 |
100 |
101 |
104 |
112 | Some name 2
113 |
114 |
115 |
118 |
119 |
120 |
123 |
134 |
139 |
148 |
151 |
152 |
153 |
154 |
155 |
156 |
159 |
162 |
170 |
171 | Some content 1
172 |
173 |
174 |
183 |
184 |
185 |
186 |
187 | `;
188 |
--------------------------------------------------------------------------------
/src/components/common/bucket-information.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Card, Collapse, Divider } from 'antd';
2 | import { EditOutlined } from '@ant-design/icons';
3 |
4 | import { IBucket } from '../../models/files/bucket';
5 | import { BucketIcon } from './icons';
6 |
7 | const { Panel } = Collapse;
8 | const { Meta } = Card;
9 |
10 | type BucketInformationProps = {
11 | bucket: IBucket;
12 | onEdit?: () => void;
13 | };
14 |
15 | export const BucketInformation = ({
16 | bucket,
17 | onEdit,
18 | }: BucketInformationProps) => {
19 | const createdDate: Date = new Date(bucket.created);
20 | const createdText = `Created: ${createdDate.toLocaleDateString()} at ${createdDate.toLocaleTimeString()}`;
21 |
22 | const modifiedDate: Date = new Date(bucket.modified);
23 | const modifiedText = `Edited: ${modifiedDate.toLocaleDateString()} at ${modifiedDate.toLocaleTimeString()}`;
24 |
25 | return (
26 |
27 |
28 |
29 | }
33 | >
34 |
35 |
36 | {createdText}
37 |
38 | {modifiedText}
39 |
40 | Total files: {Object.keys(bucket.files).length}
41 |
42 | {onEdit && (
43 | }
48 | onClick={onEdit}
49 | size="middle"
50 | >
51 | Edit bucket
52 |
53 | )}
54 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/components/common/bucket-modal.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Input, Modal, Spin } from 'antd';
2 | import { useState } from 'react';
3 | import { useDispatch } from 'react-redux';
4 | import { MySky } from 'skynet-js';
5 | import { IBucket, IReadWriteBucketInfo } from '../../models/files/bucket';
6 | import {
7 | encryptAndStoreBucket,
8 | getMySky,
9 | storeUserReadWriteHiddenBucket,
10 | } from '../../skynet/skynet';
11 |
12 | import { LoadingOutlined } from '@ant-design/icons';
13 | import { setUserKeys } from '../../features/bucket/bucket-slice';
14 | import { readWriteBucketAdded } from '../../features/user/user-slice';
15 |
16 | type BucketModalProps = {
17 | bucketInfo: IReadWriteBucketInfo;
18 | bucket?: IBucket;
19 | visible: boolean;
20 | isLoggedUser: boolean;
21 | modalTitle: string;
22 | onCancel: () => void;
23 | onError?: (err) => void;
24 | onDone: (bucketInfo: IReadWriteBucketInfo, decryptedBucket: IBucket) => void;
25 | };
26 |
27 | export const BucketModal = ({
28 | bucketInfo,
29 | bucket,
30 | visible = false,
31 | isLoggedUser = false,
32 | modalTitle,
33 | onCancel = () => {},
34 | onError = (e) => {},
35 | onDone = (a, b) => {},
36 | }: BucketModalProps) => {
37 | const [isloading, setIsLoading] = useState(false);
38 | const dispatch = useDispatch();
39 |
40 | const modalSpinner = ;
41 |
42 | const onSubmit = async (values: any) => {
43 | setIsLoading(true);
44 |
45 | const modalBucketInfo = { ...bucketInfo };
46 |
47 | let bucketToStore: IBucket = {
48 | uuid: bucketInfo.bucketID,
49 | name: values.bucketName,
50 | description: values.bucketDescription,
51 | files: {},
52 | created: Date.now(),
53 | modified: Date.now(),
54 | };
55 |
56 | if (bucket) {
57 | bucket.name = values.bucketName;
58 | bucket.description = values.bucketDescription;
59 | bucket.modified = Date.now();
60 | bucketToStore = bucket;
61 | }
62 |
63 | try {
64 | await encryptAndStoreBucket(
65 | bucketInfo.privateKey,
66 | bucketInfo.encryptionKey,
67 | bucketToStore
68 | );
69 |
70 | if (isLoggedUser) {
71 | const mySky: MySky = await getMySky();
72 | await storeUserReadWriteHiddenBucket(mySky, modalBucketInfo);
73 | }
74 | } catch (error) {
75 | onError(error);
76 | }
77 |
78 | dispatch(readWriteBucketAdded(modalBucketInfo));
79 | dispatch(
80 | setUserKeys({
81 | bucketPrivateKey: bucketInfo.privateKey,
82 | bucketEncryptionKey: bucketInfo.encryptionKey,
83 | })
84 | );
85 |
86 | setIsLoading(false);
87 | onDone(modalBucketInfo, bucketToStore);
88 | };
89 |
90 | return (
91 |
99 |
120 |
121 |
122 |
134 |
138 |
139 |
140 | {isloading && (
141 |
142 |
143 |
144 | )}
145 |
146 | );
147 | };
148 |
--------------------------------------------------------------------------------
/src/components/common/directory-tree-line/directory-tree-line.css:
--------------------------------------------------------------------------------
1 | /* overriding antd css */
2 | .ant-tree.ant-tree-directory .ant-tree-treenode:hover::before {
3 | background-color: #20bf6b40 !important;
4 | }
5 |
6 | /* overriding antd css */
7 | .ant-tree-treenode .ant-tree-node-content-wrapper {
8 | overflow-x: hidden;
9 | }
10 |
11 | .directory-tree-line {
12 | display: flex;
13 | flex-direction: row;
14 | justify-content: space-between;
15 | }
16 |
17 | .directory-tree-line__nodename {
18 | text-overflow: ellipsis;
19 | white-space: nowrap;
20 | overflow: hidden;
21 | flex: auto;
22 | font-size: 14px;
23 | }
24 |
25 | .directory-tree-line__buttons {
26 | flex: none;
27 | }
28 |
29 | .directory-tree-line__btn {
30 | margin-top: 3px;
31 | margin-bottom: 3px;
32 | }
33 |
34 | .directory-tree-line__btn--download {
35 | margin-right: 5px;
36 | }
37 |
38 | @media (min-width: 1025px) {
39 | .directory-tree-line__buttons {
40 | visibility: hidden;
41 | }
42 |
43 | .directory-tree-line:hover .directory-tree-line__buttons {
44 | visibility: visible;
45 | }
46 | }
47 |
48 | @media (max-width: 1025px) {
49 | .directory-tree-line__buttons {
50 | visibility: visible;
51 | }
52 |
53 | .directory-tree-line__btn--download {
54 | margin-left: 5px;
55 | }
56 | }
--------------------------------------------------------------------------------
/src/components/common/directory-tree-line/directory-tree-line.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Tooltip } from 'antd';
2 | import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
3 |
4 | import { isDesktop } from 'react-device-detect';
5 |
6 | import './directory-tree-line.css';
7 |
8 | type DirectoryTreeLineProps = {
9 | disabled: boolean;
10 | isLeaf: boolean;
11 | name: string;
12 | updatedAt: number;
13 | onDownloadClick: () => void;
14 | onDeleteClick?: () => void;
15 | };
16 |
17 | export const DirectoryTreeLine = ({
18 | disabled,
19 | isLeaf,
20 | name,
21 | updatedAt,
22 | onDownloadClick,
23 | onDeleteClick,
24 | }: DirectoryTreeLineProps) => {
25 | if (isLeaf) {
26 | let updatedAtDate: Date;
27 | if (updatedAt) {
28 | updatedAtDate = new Date(updatedAt);
29 | }
30 | return (
31 |
32 |
39 | {name}
40 |
41 |
42 | }
44 | className="directory-tree-line__btn directory-tree-line__btn--download"
45 | onClick={onDownloadClick}
46 | size="small"
47 | disabled={disabled}
48 | >
49 | {isDesktop ? 'Download' : ''}
50 |
51 | {onDeleteClick && (
52 | }
55 | danger
56 | onClick={onDeleteClick}
57 | size="small"
58 | disabled={disabled}
59 | >
60 | {isDesktop ? 'Delete' : ''}
61 |
62 | )}
63 |
64 |
65 | );
66 | }
67 |
68 | return {name} ;
69 | };
70 |
--------------------------------------------------------------------------------
/src/components/common/helpers.ts:
--------------------------------------------------------------------------------
1 | import { IEncryptedFile } from '../../models/files/encrypted-file';
2 | import { TUS_CHUNK_SIZE, WEB_WORKER_URL } from '../../config';
3 | import { proxy, wrap } from 'comlink';
4 | import { WorkerApi } from '../../workers/worker';
5 | import { message } from 'antd';
6 | import Xchacha20poly1305Decrypt from '../../crypto/xchacha20poly1305-decrypt';
7 | import { uploadFileFromStream } from '../../skynet/skynet';
8 | import Xchacha20poly1305Encrypt from '../../crypto/xchacha20poly1305-encrypt';
9 | import { getEndpointInCurrentPortal } from '../../portals';
10 |
11 | export const webWorkerDownload = async (
12 | encryptedFile: IEncryptedFile,
13 | setDecryptProgress,
14 | setDownloadProgress
15 | ): Promise => {
16 | const worker = new Worker(WEB_WORKER_URL);
17 | const service = wrap(worker);
18 |
19 | const url = await service.decryptFile(
20 | encryptedFile,
21 | getEndpointInCurrentPortal(),
22 | proxy(setDecryptProgress),
23 | proxy(setDownloadProgress)
24 | );
25 | if (url.startsWith('error')) {
26 | message.error(url);
27 | return;
28 | }
29 |
30 | return url;
31 | };
32 |
33 | export const simpleDownload = async (
34 | encryptedFile: IEncryptedFile,
35 | setDecryptProgress,
36 | setDownloadProgress
37 | ): Promise => {
38 | const decrypt = new Xchacha20poly1305Decrypt(
39 | encryptedFile,
40 | getEndpointInCurrentPortal()
41 | );
42 |
43 | let file: File;
44 | try {
45 | file = await decrypt.decrypt(
46 | (completed, eProgress) => {
47 | setDecryptProgress(eProgress);
48 | },
49 | (completed, dProgress) => {
50 | setDownloadProgress(dProgress);
51 | }
52 | );
53 | } catch (error) {
54 | message.error(error.message);
55 | }
56 |
57 | if (!file) {
58 | return;
59 | }
60 |
61 | return window.URL.createObjectURL(file);
62 | };
63 |
64 | export const webWorkerUploader = async (
65 | options,
66 | fileKey,
67 | setEncryptProgress
68 | ): Promise => {
69 | const { onSuccess, onError, file, onProgress } = options;
70 |
71 | const worker = new Worker(WEB_WORKER_URL);
72 | const service = wrap(worker);
73 |
74 | await service.initEncryptionReader(file, fileKey, proxy(setEncryptProgress));
75 |
76 | const rs = new ReadableStream({
77 | async start(controller) {
78 | const enc = await service.readChunk();
79 | controller.enqueue(enc.value);
80 | },
81 | async pull(controller) {
82 | const enc = await service.readChunk();
83 | if (!enc.done) {
84 | controller.enqueue(enc.value);
85 | } else {
86 | controller.close();
87 | }
88 | },
89 | });
90 |
91 | const streamSize = await service.getStreamSize();
92 |
93 | await uploadFileFromStream(
94 | fileKey,
95 | streamSize,
96 | rs,
97 | onProgress,
98 | onSuccess,
99 | onError
100 | );
101 |
102 | worker.terminate();
103 | };
104 |
105 | export const simpleUploader = async (
106 | options,
107 | fileKey,
108 | setEncryptProgress
109 | ): Promise => {
110 | const { onSuccess, onError, file, onProgress } = options;
111 |
112 | const fe = new Xchacha20poly1305Encrypt(file, fileKey);
113 | await fe.encryptAndStream((completed, eProgress) => {
114 | setEncryptProgress(eProgress);
115 | });
116 |
117 | const stream = await fe.getStream(TUS_CHUNK_SIZE);
118 |
119 | await uploadFileFromStream(
120 | fileKey,
121 | fe.getStreamSize(),
122 | stream,
123 | onProgress,
124 | onSuccess,
125 | onError
126 | );
127 | };
128 |
129 | export const downloadFile = async (
130 | encryptedFile: IEncryptedFile,
131 | setDecryptProgress,
132 | setDownloadProgress
133 | ) => {
134 | const fileName: string = encryptedFile.name;
135 | let fileUrl: string;
136 |
137 | if (window.Worker) {
138 | console.log('[Using web-workers]');
139 | fileUrl = await webWorkerDownload(
140 | encryptedFile,
141 | setDecryptProgress,
142 | setDownloadProgress
143 | );
144 | } else {
145 | fileUrl = await simpleDownload(
146 | encryptedFile,
147 | setDecryptProgress,
148 | setDownloadProgress
149 | );
150 | }
151 |
152 | const elem = window.document.createElement('a');
153 | elem.href = fileUrl;
154 | elem.download = fileName;
155 | document.body.appendChild(elem);
156 | elem.click();
157 | document.body.removeChild(elem);
158 | };
159 |
--------------------------------------------------------------------------------
/src/components/common/icons.css:
--------------------------------------------------------------------------------
1 | .friends svg {
2 | width: 150px;
3 | height: 150px;
4 | }
5 |
6 | .support svg {
7 | width: 150px;
8 | height: 150px;
9 | }
10 |
11 | .homescreen svg {
12 | width: 256px;
13 | height: 28px;
14 | }
15 |
16 | .skytransfer-logo svg {
17 | width: 90px;
18 | height: 90px;
19 | margin: 10px auto;
20 | }
21 |
22 | .bucket svg {
23 | width: 90px;
24 | margin: 10px auto;
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/common/icons.tsx:
--------------------------------------------------------------------------------
1 | import ChangePortalSVG from '../../images/change-portal.svg';
2 | import SupportUsSVG from '../../images/support.svg';
3 | import SmileSVG from '../../images/smile.svg';
4 | import HomescreenSVG from '../../images/homescreen.svg';
5 | import SkyTransferSVG from '../../images/skytransfer-logo.svg';
6 | import BucketSVG from '../../images/bucket.svg';
7 |
8 | import './icons.css';
9 |
10 | import Icon from '@ant-design/icons';
11 |
12 | export const ChangePortalIcon = () => {
13 | const component = () => ;
14 | return ;
15 | };
16 |
17 | export const SupportUsIcon = () => {
18 | const component = () => ;
19 | return ;
20 | };
21 |
22 | export const SmileIcon = () => {
23 | const component = () => ;
24 | return ;
25 | };
26 |
27 | export const HomescreenIcon = () => {
28 | const component = () => ;
29 | return ;
30 | };
31 |
32 | export const SkyTransferLogo = () => {
33 | const component = () => ;
34 | return ;
35 | };
36 |
37 | export const BucketIcon = () => {
38 | const component = () => ;
39 | return ;
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/common/notification.css:
--------------------------------------------------------------------------------
1 | .notification {
2 | text-align: center;
3 | padding: 8px 20px;
4 | font-size: 14px;
5 | background-color: #27ae60;
6 | color: #fff;
7 | }
8 |
9 | .notification a,
10 | .notification a:visited {
11 | font-weight: bold;
12 | color: #fff;
13 | }
14 |
15 | .notification a:hover {
16 | font-weight: bold;
17 | color: #fff;
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/common/notification.tsx:
--------------------------------------------------------------------------------
1 | import './notification.css';
2 | import { NotificationOutlined } from '@ant-design/icons';
3 |
4 | type HeaderNotificationProps = {
5 | content: JSX.Element;
6 | };
7 |
8 | export const HeaderNotification = ({ content }: HeaderNotificationProps) => {
9 | return (
10 |
11 |
12 | {content}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/common/qr.tsx:
--------------------------------------------------------------------------------
1 | import { QRCode } from 'react-qr-svg';
2 |
3 | import { Button, Input } from 'antd';
4 |
5 | import { CopyOutlined } from '@ant-design/icons';
6 |
7 | type QRProps = {
8 | qrValue: string;
9 | linkOnClick: () => void;
10 | };
11 |
12 | export const QR = ({ qrValue, linkOnClick }: QRProps) => {
13 | return (
14 | <>
15 |
16 |
22 |
23 | }
27 | onClick={linkOnClick}
28 | >
29 | Copy link
30 |
31 |
32 | >
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/components/common/share-modal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { message, Modal } from 'antd';
3 |
4 | import SessionManager from '../../session/session-manager';
5 |
6 | import { TabsCards } from './tabs-cards';
7 | import { QR } from './qr';
8 |
9 | type ShareModalProps = {
10 | title: string;
11 | visible: boolean;
12 | header: React.ReactNode;
13 | onCancel: () => void;
14 | shareLinkOnClick?: () => void;
15 | shareDraftLinkOnClick?: () => void;
16 | };
17 |
18 | export const ShareModal = ({
19 | title,
20 | visible,
21 | onCancel,
22 | header,
23 | shareLinkOnClick = () => {},
24 | shareDraftLinkOnClick = () => {},
25 | }: ShareModalProps) => {
26 | const copyTextToClipboard = async (text: string) => {
27 | if ('clipboard' in navigator) {
28 | return await navigator.clipboard.writeText(text);
29 | } else {
30 | return document.execCommand('copy', true, text);
31 | }
32 | };
33 |
34 | const shareTab = {
35 | name: 'Share',
36 | content: (
37 | <>
38 | {
41 | copyTextToClipboard(SessionManager.readOnlyLink);
42 | message.info('SkyTransfer bucket link copied');
43 | shareLinkOnClick();
44 | }}
45 | />
46 | >
47 | ),
48 | };
49 |
50 | const shareDraftTab = {
51 | name: 'Share draft',
52 | content: (
53 | <>
54 | {
57 | copyTextToClipboard(SessionManager.readWriteLink);
58 | message.info('SkyTransfer bucket draft link copied');
59 | shareDraftLinkOnClick();
60 | }}
61 | />
62 | >
63 | ),
64 | };
65 |
66 | const tabs = SessionManager.isReadOnlyFromLink()
67 | ? [shareTab]
68 | : [shareTab, shareDraftTab];
69 |
70 | return (
71 |
78 | {header}
79 |
80 |
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/src/components/common/tabs-cards.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import '@testing-library/jest-dom/extend-expect';
4 |
5 | import { TabsCards } from './tabs-cards';
6 |
7 | describe('TabsCards', () => {
8 | test('renders correctly when there are no values', () => {
9 | const { container } = render( );
10 |
11 | expect(container).toMatchSnapshot();
12 | });
13 |
14 | test('renders correctly with values', () => {
15 | const values = [
16 | {
17 | name: 'Some name 1',
18 | content: Some content 1
,
19 | },
20 | {
21 | name: 'Some name 2',
22 | content: Some content 2
,
23 | },
24 | ];
25 |
26 | const { container } = render( );
27 |
28 | expect(container).toMatchSnapshot();
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/common/tabs-cards.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from 'antd';
2 |
3 | const { TabPane } = Tabs;
4 |
5 | type TabsCardValue = {
6 | name: string;
7 | content: React.ReactNode;
8 | };
9 |
10 | type TabCardProps = {
11 | tabType?: 'line' | 'card' | 'editable-card';
12 | values: TabsCardValue[];
13 | };
14 |
15 | export const TabsCards = ({ values, tabType = 'card' }: TabCardProps) => {
16 | const components = values.map((value: TabsCardValue, index: number) => (
17 |
18 | {value.content}
19 |
20 | ));
21 |
22 | return {components} ;
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/filelist/file-list.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import { useNavigate, useParams } from 'react-router-dom';
3 |
4 | import { Badge, Button, Divider, Empty, message, Spin, Tree } from 'antd';
5 | import {
6 | DownloadOutlined,
7 | DownOutlined,
8 | FolderAddOutlined,
9 | ShareAltOutlined,
10 | } from '@ant-design/icons';
11 | import { renderTree } from '../../utils/walker';
12 | import { getDecryptedBucket, getMySky } from '../../skynet/skynet';
13 |
14 | import { ActivityBars } from '../uploader/activity-bar';
15 |
16 | import { DirectoryTreeLine } from '../common/directory-tree-line/directory-tree-line';
17 | import { DecryptedBucket, IBucket } from '../../models/files/bucket';
18 | import { IEncryptedFile } from '../../models/files/encrypted-file';
19 |
20 | import { useDispatch, useSelector } from 'react-redux';
21 | import { BucketInformation } from '../common/bucket-information';
22 | import {
23 | IBucketState,
24 | selectBucket,
25 | setUserKeys,
26 | } from '../../features/bucket/bucket-slice';
27 | import { ShareModal } from '../common/share-modal';
28 | import { addReadOnlyBucket, selectUser } from '../../features/user/user-slice';
29 | import { IUserState, UserStatus } from '../../models/user';
30 | import { downloadFile } from '../common/helpers';
31 |
32 | const { DownloadActivityBar } = ActivityBars;
33 |
34 | const { DirectoryTree } = Tree;
35 |
36 | const useConstructor = (callBack = () => {}) => {
37 | const hasBeenCalled = useRef(false);
38 | if (hasBeenCalled.current) return;
39 | callBack();
40 | hasBeenCalled.current = true;
41 | };
42 |
43 | const FileList = () => {
44 | const { transferKey, encryptionKey } = useParams();
45 | const [loading, setlLoading] = useState(true);
46 | const [showShareBucketModal, setShowShareBucketModal] = useState(false);
47 | const navigate = useNavigate();
48 | const dispatch = useDispatch();
49 |
50 | const [decryptedBucket, setDecryptedBucket] = useState();
51 |
52 | const userState: IUserState = useSelector(selectUser);
53 | const bucketState: IBucketState = useSelector(selectBucket);
54 |
55 | useConstructor(async () => {
56 | if (transferKey && transferKey.length === 128) {
57 | dispatch(
58 | setUserKeys({
59 | bucketPrivateKey: transferKey,
60 | bucketEncryptionKey: encryptionKey,
61 | })
62 | );
63 | navigate('/');
64 | }
65 |
66 | // transferKey is a publicKey
67 | const bucket: IBucket = await getDecryptedBucket(
68 | transferKey,
69 | encryptionKey
70 | );
71 | if (!bucket) {
72 | setlLoading(false);
73 | return;
74 | }
75 |
76 | setDecryptedBucket(new DecryptedBucket(bucket));
77 | setlLoading(false);
78 | });
79 |
80 | const [downloadProgress, setDownloadProgress] = useState(0);
81 | const [decryptProgress, setDecryptProgress] = useState(0);
82 |
83 | useEffect(() => {
84 | if (downloadProgress === 100) {
85 | setTimeout(() => {
86 | setDownloadProgress(0);
87 | }, 500);
88 | }
89 | }, [downloadProgress]);
90 |
91 | useEffect(() => {
92 | if (decryptProgress === 100) {
93 | setTimeout(() => {
94 | setDecryptProgress(0);
95 | }, 500);
96 | }
97 | }, [decryptProgress]);
98 |
99 | const getFileBy = (key: string): IEncryptedFile => {
100 | for (let path in decryptedBucket.files) {
101 | if (decryptedBucket.files[path].uuid === key.split('_')[0]) {
102 | return decryptedBucket.files[path];
103 | }
104 | }
105 |
106 | throw Error('could not find the file');
107 | };
108 |
109 | const bucketHasFiles =
110 | decryptedBucket &&
111 | decryptedBucket.files &&
112 | Object.keys(decryptedBucket.files).length > 0;
113 |
114 | const closeShareBucketModal = () => {
115 | setShowShareBucketModal(false);
116 | };
117 |
118 | const pinBucket = async (bucketID: string) => {
119 | const mySky = await getMySky();
120 | dispatch(
121 | addReadOnlyBucket(mySky, {
122 | publicKey: transferKey,
123 | encryptionKey: encryptionKey,
124 | bucketID,
125 | })
126 | );
127 | };
128 |
129 | const isUserLogged = (): boolean => {
130 | return userState.status === UserStatus.Logged;
131 | };
132 |
133 | const isBucketPinned = (bucketID: string): boolean => {
134 | return isUserLogged() && bucketID in userState.buckets.readOnly;
135 | };
136 |
137 | return (
138 |
139 | {decryptedBucket && decryptedBucket.files && (
140 | <>
141 | {isBucketPinned(decryptedBucket.uuid) && (
142 |
143 | )}
144 |
145 | >
146 | )}
147 |
148 |
149 | setShowShareBucketModal(true)}
154 | icon={ }
155 | >
156 | Share bucket
157 |
158 | {decryptedBucket && isUserLogged() && (
159 | pinBucket(decryptedBucket.uuid)}
169 | icon={ }
170 | >
171 | {isBucketPinned(decryptedBucket.uuid)
172 | ? 'Already pinned'
173 | : 'Pin bucket'}
174 |
175 | )}
176 |
177 |
178 |
Shared files
179 | {bucketHasFiles ? (
180 | <>
181 |
182 |
186 |
{decryptedBucket.name}
187 |
}
194 | treeData={renderTree(decryptedBucket.files)}
195 | selectable={false}
196 | titleRender={(node) => {
197 | const key: string = `${node.key}`;
198 | const encryptedFile = getFileBy(key);
199 | return encryptedFile ? (
200 |
{
206 | if (!node.isLeaf) {
207 | return;
208 | }
209 | if (encryptedFile) {
210 | message.loading(`Download and decryption started`);
211 | downloadFile(
212 | encryptedFile,
213 | setDecryptProgress,
214 | setDownloadProgress
215 | );
216 | }
217 | }}
218 | />
219 | ) : (
220 | ''
221 | );
222 | }}
223 | />
224 |
225 |
226 | }
228 | size="middle"
229 | onClick={async () => {
230 | message.loading(`Download and decryption started`);
231 | for (const encyptedFile in decryptedBucket.files) {
232 | const file = decryptedBucket.files[encyptedFile];
233 | await downloadFile(
234 | file,
235 | setDecryptProgress,
236 | setDownloadProgress
237 | );
238 | }
239 | }}
240 | >
241 | Download all files
242 |
243 |
244 | >
245 | ) : (
246 |
247 | {loading ? : No shared data found }
248 |
249 | )}
250 |
{
254 | setShowShareBucketModal(false);
255 | }}
256 | header={Copy the link and share the bucket.
}
257 | shareLinkOnClick={closeShareBucketModal}
258 | shareDraftLinkOnClick={closeShareBucketModal}
259 | />
260 |
261 | );
262 | };
263 |
264 | export default FileList;
265 |
--------------------------------------------------------------------------------
/src/components/header/header.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ilkamo/skytransfer/c8309603dbe6acba24092e1a6e99743f39fe07e7/src/components/header/header.css
--------------------------------------------------------------------------------
/src/components/header/header.tsx:
--------------------------------------------------------------------------------
1 | import './header.css';
2 |
3 | import { Layout, Menu } from 'antd';
4 |
5 | import { getCurrentPortal, getPortals } from '../../portals';
6 |
7 | import {
8 | CopyOutlined,
9 | HeartOutlined,
10 | RedoOutlined,
11 | UnorderedListOutlined,
12 | } from '@ant-design/icons';
13 |
14 | import SessionManager from '../../session/session-manager';
15 | import { useLocation, useNavigate } from 'react-router-dom';
16 | import { useEffect, useState } from 'react';
17 | import { ChangePortalIcon } from '../common/icons';
18 |
19 | const { Header } = Layout;
20 | const { SubMenu } = Menu;
21 |
22 | const AppHeader = () => {
23 | const navigate = useNavigate();
24 | let location = useLocation();
25 |
26 | const [canResumeSession, setCanResumeSession] = useState(false);
27 | const [canPublishSession, setCanPublishSession] = useState(false);
28 |
29 | useEffect(() => {
30 | setCanResumeSession(
31 | location.pathname !== '/' && SessionManager.canResume()
32 | );
33 | setCanPublishSession(SessionManager.canResume());
34 | }, [location]);
35 |
36 | const portals = getPortals().map((x) => {
37 | const changePortal = () => {
38 | window.open(`https://skytransfer.hns.${x.domain}/`, '_self');
39 | };
40 |
41 | return (
42 |
43 | {x.displayName}
44 |
45 | );
46 | });
47 |
48 | return (
49 |
50 |
55 | {
58 | navigate('/');
59 | }}
60 | disabled={!canResumeSession}
61 | icon={ }
62 | >
63 | Resume draft
64 |
65 | {
68 | navigate('/buckets');
69 | }}
70 | disabled={!canPublishSession}
71 | icon={ }
72 | >
73 | Buckets
74 |
75 | {
78 | navigate('/about');
79 | }}
80 | icon={ }
81 | >
82 | About
83 |
84 | {
87 | navigate('/support-us');
88 | }}
89 | icon={ }
90 | >
91 | Support Us
92 |
93 | }
98 | >
99 | {portals}
100 |
101 |
102 |
103 | );
104 | };
105 |
106 | export default AppHeader;
107 |
--------------------------------------------------------------------------------
/src/components/redirect-v1/redirect-v1.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useParams } from 'react-router-dom';
3 |
4 | const RedirectV1 = () => {
5 | const { transferKey, encryptionKey } = useParams();
6 |
7 | useEffect(() => {
8 | window.location.replace(
9 | `https://skytransfer-v1.hns.siasky.net/#/${transferKey}/${encryptionKey}`
10 | );
11 | }, [transferKey, encryptionKey]);
12 |
13 | return <>>;
14 | };
15 |
16 | export default RedirectV1;
17 |
--------------------------------------------------------------------------------
/src/components/support-us/support-us.css:
--------------------------------------------------------------------------------
1 | .support-us .title {
2 | color: #20bf6b;
3 | font-weight: bold;
4 | }
5 |
6 | .support-us .divider-text {
7 | font-size: 18px;
8 | }
--------------------------------------------------------------------------------
/src/components/support-us/support-us.tsx:
--------------------------------------------------------------------------------
1 | import { SmileIcon, SupportUsIcon } from '../common/icons';
2 | import './support-us.css';
3 |
4 | import { Divider, Typography } from 'antd';
5 |
6 | const { Title } = Typography;
7 |
8 | const SupportUs = () => {
9 | return (
10 |
11 |
12 | Support us
13 |
14 |
15 |
16 |
17 |
18 |
19 | Thanks for using SkyTransfer , dear
20 | friend.
21 |
22 |
We want the World to be a better place, in our own small way.
23 |
24 | SkyTransfer is a tool we developed with love, for Us, for You, for the
25 | World. We hope you enjoy it and find it useful. Forget how frustrating
26 | can be, sometimes, sharing files. Reimagine how file sharing experience
27 | should be.
28 |
29 |
30 |
31 |
32 |
33 |
Support and motivate Us
34 |
35 | We are constantly shipping new features. We don't want to steal your
36 | time but if you really want, you can support Us with a donation. It's
37 | your way of returning the love we invested in developing SkyTransfer. We
38 | would appreciate it very much.
39 |
40 |
Gitcoin Grants
41 |
42 | SkyTransfer is an open source project and it is part of{' '}
43 |
48 | Gitcoin Grants
49 | {' '}
50 | to raise funds for the future development. Gitcoin's fund matching pool
51 | relies on the number of donors a project has, so every gwei or dai
52 | counts!
53 |
54 |
Buy Us a coffee
55 |
56 |
61 |
66 |
67 |
68 |
69 |
Siacoin wallet address
70 |
71 | a34ac5a2aa1d5174b1a9289584ab4cdb5d2f99fa24de4a86d592fb02b2f81b754ff97af0e6e4
72 |
73 |
74 |
75 | Thanks for your time. Take care of you.
76 |
77 |
78 | Kamil & Michał,
79 |
80 | SkyTransfer creators.
81 |
82 |
83 | );
84 | };
85 |
86 | export default SupportUs;
87 |
--------------------------------------------------------------------------------
/src/components/uploader/activity-bar.tsx:
--------------------------------------------------------------------------------
1 | import { Badge, Progress, Tooltip } from 'antd';
2 |
3 | import { CloudDownloadOutlined, SecurityScanOutlined } from '@ant-design/icons';
4 |
5 | type UploadActivityBarProps = {
6 | encryptProgress: number;
7 | };
8 |
9 | type DownloadActivityBarProps = {
10 | downloadProgress: number;
11 | decryptProgress: number;
12 | };
13 |
14 | const progressBadge = (progress) => {
15 | return (
16 |
20 | );
21 | };
22 |
23 | const progressMaxWidth = 100;
24 |
25 | const UploadActivityBar = ({ encryptProgress }: UploadActivityBarProps) => {
26 | return (
27 |
28 |
33 |
34 | Encrypt
35 | {progressBadge(encryptProgress)}
36 |
37 |
48 |
49 | );
50 | };
51 |
52 | const DownloadActivityBar = ({
53 | downloadProgress,
54 | decryptProgress,
55 | }: DownloadActivityBarProps) => {
56 | return (
57 |
58 |
63 |
64 | Download
65 | {progressBadge(downloadProgress)}
66 |
67 |
78 |
79 |
84 |
85 | Decrypt
86 | {progressBadge(decryptProgress)}
87 |
88 |
99 |
100 | );
101 | };
102 |
103 | export const ActivityBars = {
104 | UploadActivityBar: UploadActivityBar,
105 | DownloadActivityBar: DownloadActivityBar,
106 | };
107 |
--------------------------------------------------------------------------------
/src/components/uploader/dragger-content.tsx:
--------------------------------------------------------------------------------
1 | import { SkyTransferLogo } from '../common/icons';
2 | import { APP_VERSION } from '../../version';
3 |
4 | type DraggerContentProps = {
5 | onlyClickable: boolean;
6 | draggableMessage: string;
7 | };
8 |
9 | export const DraggerContent = ({
10 | onlyClickable,
11 | draggableMessage,
12 | }: DraggerContentProps) => {
13 | return (
14 | <>
15 |
16 | {onlyClickable ? (
17 | Click here to upload
18 | ) : (
19 | {draggableMessage}
20 | )}
21 |
22 |
23 |
24 |
25 |
26 | SkyTransfer {APP_VERSION}
27 |
28 |
29 | >
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/uploader/uploader.css:
--------------------------------------------------------------------------------
1 | .drop-container {
2 | border: 1px dashed #bdc3c7 !important;
3 | border-radius: 2px !important;
4 | background-color: white;
5 | padding: 10px;
6 | }
7 |
8 | .drop-container:hover {
9 | border: 1px dashed #20bf6b !important;
10 | }
11 |
12 | .ant-menu-item:hover {
13 | background-color: #20bf6b !important;
14 | }
15 |
16 | .drop-container .version {
17 | font-size: 12px;
18 | color: #b2bec3;
19 | }
20 |
21 | .logo {
22 | font-size: 30px;
23 | color: #20bf6b;
24 | font-weight: bold;
25 | }
26 |
27 | .file-error {
28 | display: inline-block;
29 | color: #9aa9bb;
30 | }
31 |
32 | .error-message {
33 | color: red;
34 | margin: 20px auto;
35 | }
36 |
37 | .default-margin {
38 | margin: 20px auto;
39 | }
40 |
41 | .file-remove {
42 | cursor: pointer;
43 | color: red;
44 | }
45 |
46 | .file-input {
47 | display: none;
48 | }
49 |
50 | .file-tree {
51 | font-size: 16px;
52 | background: transparent;
53 | padding: 10px;
54 | }
55 |
56 | .file-list {
57 | border: 1px dashed #ccc;
58 | border-radius: 2px !important;
59 | background: #fafafa;
60 | }
61 |
62 | .ant-tree-switcher {
63 | background: transparent !important;
64 | }
65 |
66 | .progress {
67 | margin: 0;
68 | padding: 0;
69 | cursor: pointer;
70 | }
71 |
72 | .activity-bar {
73 | font-size: 12px;
74 | color: #2c3e50;
75 | text-align: center;
76 | }
77 |
78 | .activity-bar .label {
79 | margin-right: 5px;
80 | }
81 |
82 | .ant-upload-text {
83 | font-size: 16px !important;
84 | }
--------------------------------------------------------------------------------
/src/components/uploader/uploader.tsx:
--------------------------------------------------------------------------------
1 | import './uploader.css';
2 |
3 | import { useEffect, useState } from 'react';
4 |
5 | import { EncryptionType } from '../../models/encryption';
6 |
7 | import { isMobile } from 'react-device-detect';
8 |
9 | import { v4 as uuid } from 'uuid';
10 |
11 | import {
12 | Alert,
13 | Badge,
14 | Button,
15 | Divider,
16 | Empty,
17 | message,
18 | Modal,
19 | Spin,
20 | Tree,
21 | Upload,
22 | } from 'antd';
23 |
24 | import {
25 | DownloadOutlined,
26 | DownOutlined,
27 | FolderAddOutlined,
28 | LoadingOutlined,
29 | QuestionCircleOutlined,
30 | ShareAltOutlined,
31 | } from '@ant-design/icons';
32 |
33 | import { UploadFile } from 'antd/lib/upload/interface';
34 |
35 | import { renderTree } from '../../utils/walker';
36 | import {
37 | DEFAULT_ENCRYPTION_TYPE,
38 | MAX_PARALLEL_UPLOAD,
39 | MIN_SKYDB_SYNC_FACTOR,
40 | SKYDB_SYNC_FACTOR,
41 | } from '../../config';
42 | import { TabsCards } from '../common/tabs-cards';
43 | import { ActivityBars } from './activity-bar';
44 |
45 | import {
46 | encryptAndStoreBucket,
47 | getDecryptedBucket,
48 | getMySky,
49 | pinFile,
50 | } from '../../skynet/skynet';
51 | import { DraggerContent } from './dragger-content';
52 | import { ShareModal } from '../common/share-modal';
53 | import { DirectoryTreeLine } from '../common/directory-tree-line/directory-tree-line';
54 | import { IEncryptedFile } from '../../models/files/encrypted-file';
55 | import {
56 | DecryptedBucket,
57 | IBucket,
58 | IReadWriteBucketInfo,
59 | } from '../../models/files/bucket';
60 | import { IFileData } from '../../models/files/file-data';
61 | import { genKeyPairAndSeed } from 'skynet-js';
62 | import { ChunkResolver } from '../../crypto/chunk-resolver';
63 |
64 | import { addReadWriteBucket, selectUser } from '../../features/user/user-slice';
65 | import { useDispatch, useSelector } from 'react-redux';
66 | import { publicKeyFromPrivateKey } from '../../crypto/crypto';
67 | import { IUserState, UserStatus } from '../../models/user';
68 | import { BucketModal } from '../common/bucket-modal';
69 | import { BucketInformation } from '../common/bucket-information';
70 | import {
71 | IBucketState,
72 | selectBucket,
73 | setUserKeys,
74 | } from '../../features/bucket/bucket-slice';
75 | import {
76 | downloadFile,
77 | simpleUploader,
78 | webWorkerUploader,
79 | } from '../common/helpers';
80 |
81 | const { DirectoryTree } = Tree;
82 | const { Dragger } = Upload;
83 | const { DownloadActivityBar, UploadActivityBar } = ActivityBars;
84 |
85 | const sleep = (ms): Promise => {
86 | return new Promise((resolve) => setTimeout(resolve, ms));
87 | };
88 |
89 | let uploadCount = 0;
90 | let skydbSyncInProgress = false;
91 |
92 | const generateRandomDecryptedBucket = (): DecryptedBucket => {
93 | const randName = (Math.random() + 1).toString(36).substring(7);
94 | return new DecryptedBucket({
95 | uuid: uuid(),
96 | name: `skytransfer-${randName}`,
97 | description:
98 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
99 | files: {},
100 | created: Date.now(),
101 | modified: Date.now(),
102 | });
103 | };
104 |
105 | const Uploader = () => {
106 | const [errorMessage, setErrorMessage] = useState('');
107 | const [decryptedBucket, setDecryptedBucket] = useState();
108 |
109 | const [toStoreInSkyDBCount, setToStoreInSkyDBCount] = useState(0);
110 | const [toRemoveFromSkyDBCount, setToRemoveFromSkyDBCount] = useState(0);
111 |
112 | const [uploading, setUploading] = useState(false);
113 | const [showShareBucketModal, setShowShareBucketModal] = useState(false);
114 | const [loading, setLoading] = useState(true);
115 | const [uidsOfErrorFiles, setUidsOfErrorFiles] = useState([]);
116 | const [fileListToUpload, setFileListToUpload] = useState([]);
117 |
118 | const [editBucketModalVisible, setEditBucketModalVisible] = useState(false);
119 | const [bucketInfo, setBucketInfo] = useState();
120 |
121 | const dispatch = useDispatch();
122 |
123 | const userState: IUserState = useSelector(selectUser);
124 | const bucketState: IBucketState = useSelector(selectBucket);
125 |
126 | const closeShareBucketModal = () => {
127 | setShowShareBucketModal(false);
128 | };
129 |
130 | const loadBucket = async () => {
131 | let bucket: IBucket = await getDecryptedBucket(
132 | publicKeyFromPrivateKey(bucketState.privateKey),
133 | bucketState.encryptionKey
134 | );
135 |
136 | let decryptedBucket = generateRandomDecryptedBucket();
137 | if (bucket) {
138 | decryptedBucket = new DecryptedBucket(bucket);
139 | } else {
140 | /*
141 | Bucket can't be loaded and the reason could be a Skynet issue.
142 | Generate a new set of keys in order to create a new bucket and be sure that no existing
143 | bucket will be overwritten.
144 | */
145 | const privateKey = genKeyPairAndSeed().privateKey;
146 | const encryptionKey = genKeyPairAndSeed().privateKey;
147 | await encryptAndStoreBucket(privateKey, encryptionKey, decryptedBucket);
148 | dispatch(
149 | setUserKeys({
150 | bucketPrivateKey: privateKey,
151 | bucketEncryptionKey: encryptionKey,
152 | })
153 | );
154 | }
155 |
156 | setBucketInfo({
157 | bucketID: decryptedBucket.uuid,
158 | privateKey: bucketState.privateKey,
159 | encryptionKey: bucketState.encryptionKey,
160 | });
161 |
162 | setDecryptedBucket(new DecryptedBucket(decryptedBucket));
163 | setLoading(false);
164 | };
165 |
166 | useEffect(() => {
167 | if (bucketState.encryptionKey !== null) {
168 | loadBucket();
169 | }
170 | }, [bucketState.encryptionKey]);
171 |
172 | const updateFilesInSkyDB = async () => {
173 | setLoading(true);
174 | skydbSyncInProgress = true;
175 | const skyDbMessageKey = 'skydbMessageKey';
176 | try {
177 | message.loading({
178 | content: 'Syncing files in SkyDB...',
179 | key: skyDbMessageKey,
180 | });
181 | await encryptAndStoreBucket(
182 | bucketState.privateKey,
183 | bucketState.encryptionKey,
184 | decryptedBucket
185 | );
186 |
187 | message.success({ content: 'Sync completed', key: skyDbMessageKey });
188 | setToStoreInSkyDBCount(0);
189 | setToRemoveFromSkyDBCount(0);
190 | } catch (error) {
191 | setErrorMessage('Could not sync session encrypted files: ' + error);
192 | }
193 |
194 | skydbSyncInProgress = false;
195 | setLoading(false);
196 | };
197 |
198 | const skyDBSyncer = async () => {
199 | const stillInProgressFilesCount =
200 | fileListToUpload.length - uidsOfErrorFiles.length;
201 |
202 | const intervalSkyDBSync =
203 | toStoreInSkyDBCount > SKYDB_SYNC_FACTOR &&
204 | stillInProgressFilesCount > MIN_SKYDB_SYNC_FACTOR;
205 |
206 | const uploadCompletedSkyDBSync =
207 | toStoreInSkyDBCount > 0 &&
208 | Object.keys(decryptedBucket.files).length > 0 &&
209 | stillInProgressFilesCount === 0;
210 |
211 | const fileListEditedSkyDBSync =
212 | toRemoveFromSkyDBCount > 0 && stillInProgressFilesCount === 0;
213 |
214 | if (stillInProgressFilesCount === 0 && toStoreInSkyDBCount === 0) {
215 | setUploading(false);
216 | }
217 |
218 | if (
219 | !skydbSyncInProgress &&
220 | (intervalSkyDBSync || uploadCompletedSkyDBSync || fileListEditedSkyDBSync)
221 | ) {
222 | await updateFilesInSkyDB();
223 |
224 | if (uploadCompletedSkyDBSync) {
225 | setShowShareBucketModal(true);
226 | }
227 | }
228 | };
229 |
230 | useEffect(() => {
231 | skyDBSyncer();
232 | });
233 |
234 | const [downloadProgress, setDownloadProgress] = useState(0);
235 | const [decryptProgress, setDecryptProgress] = useState(0);
236 | const [encryptProgress, setEncryptProgress] = useState(0);
237 |
238 | useEffect(() => {
239 | if (downloadProgress === 100) {
240 | setTimeout(() => {
241 | setDownloadProgress(0);
242 | }, 500);
243 | }
244 | }, [downloadProgress]);
245 |
246 | useEffect(() => {
247 | if (decryptProgress === 100) {
248 | setTimeout(() => {
249 | setDecryptProgress(0);
250 | }, 500);
251 | }
252 | }, [decryptProgress]);
253 |
254 | useEffect(() => {
255 | if (encryptProgress === 100) {
256 | setTimeout(() => {
257 | setEncryptProgress(0);
258 | }, 500);
259 | }
260 | }, [encryptProgress]);
261 |
262 | const queueParallelEncryption = (options): Promise => {
263 | return new Promise(async (resolve) => {
264 | while (uploadCount > MAX_PARALLEL_UPLOAD) {
265 | await sleep(1000);
266 | }
267 |
268 | uploadCount++;
269 |
270 | const fileKey = genKeyPairAndSeed().privateKey;
271 |
272 | let uploadFunc;
273 | if (window.Worker) {
274 | console.log('[Using web-workers]');
275 | uploadFunc = webWorkerUploader;
276 | } else {
277 | uploadFunc = simpleUploader;
278 | }
279 |
280 | resolve(uploadFunc(options, fileKey, setEncryptProgress));
281 | });
282 | };
283 |
284 | const queueAndUploadFile = async (options) => {
285 | await queueParallelEncryption(options);
286 | };
287 |
288 | const draggerConfig = {
289 | name: 'file',
290 | multiple: true,
291 | fileList: fileListToUpload,
292 | directory: !isMobile,
293 | showUploadList: {
294 | showRemoveIcon: true,
295 | },
296 | customRequest: queueAndUploadFile,
297 | openFileDialogOnClick: true,
298 | onChange(info) {
299 | setShowShareBucketModal(false);
300 | setUploading(true);
301 |
302 | setFileListToUpload(info.fileList.map((x) => x)); // Note: A new object must be used here!!!
303 |
304 | const { status } = info.file;
305 |
306 | // error | success | done | uploading | removed
307 | switch (status) {
308 | case 'removed': {
309 | if (
310 | uidsOfErrorFiles.findIndex((uid) => uid === info.file.uid) === -1
311 | ) {
312 | uploadCount--;
313 | }
314 | setUidsOfErrorFiles((p) => p.filter((uid) => uid !== info.file.uid));
315 | setFileListToUpload((prev) =>
316 | prev.filter((f) => f.uid !== info.file.uid)
317 | );
318 | break;
319 | }
320 | case 'error': {
321 | uploadCount--;
322 | setUidsOfErrorFiles((p) => [...p, info.file.uid]);
323 | message.error(`${info.file.name} file upload failed.`);
324 | break;
325 | }
326 | case 'done': {
327 | const relativePath = info.file.originFileObj.webkitRelativePath
328 | ? info.file.originFileObj.webkitRelativePath
329 | : info.file.name;
330 |
331 | const cr = new ChunkResolver(DEFAULT_ENCRYPTION_TYPE);
332 |
333 | const tempFileData: IFileData = {
334 | uuid: uuid(),
335 | size: info.file.size,
336 | chunkSize: cr.decryptChunkSize,
337 | encryptionType: EncryptionType[DEFAULT_ENCRYPTION_TYPE],
338 | url: info.file.response.skylink,
339 | key: info.file.response.fileKey,
340 | hash: '', // TODO: add hash
341 | ts: Date.now(),
342 | encryptedSize: info.file.response.encryptedFileSize,
343 | relativePath: relativePath,
344 | };
345 |
346 | // TODO: just for test, move me somewhere near the uploader and use await
347 | pinFile(info.file.response.skylink);
348 |
349 | if (decryptedBucket.encryptedFileExists(relativePath)) {
350 | setDecryptedBucket((p) => {
351 | const f = p.files[relativePath];
352 | f.modified = Date.now();
353 | f.name = info.file.name;
354 | f.file = tempFileData;
355 | f.history.push(tempFileData);
356 | f.version++;
357 |
358 | return p;
359 | });
360 | } else {
361 | const encryptedFile: IEncryptedFile = {
362 | uuid: uuid(),
363 | file: tempFileData,
364 | created: Date.now(),
365 | name: info.file.name,
366 | modified: Date.now(),
367 | mimeType: info.file.type,
368 | history: [tempFileData],
369 | version: 0,
370 | };
371 |
372 | setDecryptedBucket((p) => {
373 | p.files[relativePath] = encryptedFile;
374 | p.modified = Date.now();
375 | return p;
376 | });
377 | }
378 |
379 | message.success({
380 | content: `${info.file.name} file uploaded successfully.`,
381 | key: 'uploadMessageKey',
382 | });
383 |
384 | setToStoreInSkyDBCount((prev) => prev + 1);
385 | uploadCount--;
386 | setFileListToUpload((prev) =>
387 | prev.filter((f) => f.uid !== info.file.uid)
388 | );
389 | break;
390 | }
391 | }
392 | },
393 | };
394 |
395 | const deleteConfirmModal = (filename: string, onDeleteClick: () => void) => {
396 | Modal.confirm({
397 | title: 'Warning',
398 | icon: ,
399 | content: `File ${filename} will be deleted. Are you sure?`,
400 | okText: 'Delete',
401 | cancelText: 'Cancel',
402 | onOk: onDeleteClick,
403 | });
404 | };
405 |
406 | const loaderIcon = (
407 |
408 | );
409 |
410 | const getFileBy = (key: string): IEncryptedFile => {
411 | for (let path in decryptedBucket.files) {
412 | if (decryptedBucket.files[path].uuid === key.split('_')[0]) {
413 | return decryptedBucket.files[path];
414 | }
415 | }
416 | };
417 |
418 | const bucketHasFiles =
419 | decryptedBucket &&
420 | decryptedBucket.files &&
421 | Object.keys(decryptedBucket.files).length > 0;
422 |
423 | const pinBucket = async (bucketID: string) => {
424 | const mySky = await getMySky();
425 | dispatch(
426 | addReadWriteBucket(mySky, {
427 | privateKey: bucketState.privateKey,
428 | encryptionKey: bucketState.encryptionKey,
429 | bucketID,
430 | })
431 | );
432 |
433 | // TODO: Handle errors from skynet methods
434 | };
435 |
436 | const isUserLogged = (): boolean => {
437 | return userState.status === UserStatus.Logged;
438 | };
439 |
440 | const isBucketPinned = (bucketID: string): boolean => {
441 | return isUserLogged() && bucketID in userState.buckets.readWrite;
442 | };
443 |
444 | const isLoading = uploading || loading;
445 | return (
446 |
447 | {errorMessage ? (
448 |
454 | ) : (
455 | ''
456 | )}
457 |
458 | {decryptedBucket && decryptedBucket.files && (
459 | <>
460 | {isBucketPinned(decryptedBucket.uuid) && (
461 |
462 | )}
463 |
setEditBucketModalVisible(true)}
466 | />
467 | >
468 | )}
469 |
470 |
477 |
484 |
488 |
489 |
490 |
491 | ),
492 | },
493 | {
494 | name: 'Upload directory',
495 | content: (
496 |
497 |
503 |
507 |
508 |
509 |
510 | ),
511 | },
512 | ]}
513 | />
514 |
515 |
516 | setShowShareBucketModal(true)}
521 | icon={ }
522 | >
523 | Share bucket
524 |
525 | {decryptedBucket && isUserLogged() && (
526 | pinBucket(decryptedBucket.uuid)}
536 | icon={ }
537 | >
538 | {isBucketPinned(decryptedBucket.uuid)
539 | ? 'Already pinned'
540 | : 'Pin bucket'}
541 |
542 | )}
543 |
544 |
545 | {bucketInfo && decryptedBucket && (
546 | setEditBucketModalVisible(false)}
551 | isLoggedUser={isUserLogged()}
552 | modalTitle="Edit bucket"
553 | onDone={(bucketInfo, bucket) => {
554 | setBucketInfo(bucketInfo);
555 | setDecryptedBucket((p) => {
556 | p.description = bucket.description;
557 | p.name = bucket.name;
558 | p.modified = bucket.modified;
559 | return p;
560 | });
561 | setEditBucketModalVisible(false);
562 | }}
563 | onError={(e) => {
564 | console.error(e);
565 | setEditBucketModalVisible(false);
566 | }}
567 | />
568 | )}
569 |
570 | {bucketHasFiles ? (
571 |
572 |
576 | {isLoading && (
577 |
578 |
579 | Sync in progress...
580 |
581 | )}
582 |
{decryptedBucket.name}
583 |
}
591 | treeData={renderTree(decryptedBucket.files)}
592 | selectable={false}
593 | titleRender={(node) => {
594 | const key: string = `${node.key}`;
595 | const encryptedFile = getFileBy(key);
596 | return encryptedFile ? (
597 |
{
603 | if (!node.isLeaf) {
604 | return;
605 | }
606 | if (encryptedFile) {
607 | message.loading(`Download and decryption started`);
608 | downloadFile(
609 | encryptedFile,
610 | setDecryptProgress,
611 | setDownloadProgress
612 | );
613 | }
614 | }}
615 | onDeleteClick={() => {
616 | deleteConfirmModal(node.title.toString(), () => {
617 | setDecryptedBucket((p) => {
618 | for (let path in decryptedBucket.files) {
619 | if (
620 | decryptedBucket.files[path].uuid ===
621 | key.split('_')[0]
622 | ) {
623 | delete decryptedBucket.files[path];
624 | }
625 | }
626 | return p;
627 | });
628 | setToRemoveFromSkyDBCount((prev) => prev + 1);
629 | });
630 | }}
631 | />
632 | ) : (
633 | ''
634 | );
635 | }}
636 | />
637 |
638 | ) : (
639 |
640 | {loading ? : No uploaded data }
641 |
642 | )}
643 |
644 | {!isLoading && bucketHasFiles && (
645 |
646 | }
648 | size="middle"
649 | type="primary"
650 | onClick={async () => {
651 | message.loading({
652 | content: `Download and decryption started`,
653 | key: 'loadingMessageKey',
654 | });
655 | for (const encyptedFile in decryptedBucket.files) {
656 | const file = decryptedBucket.files[encyptedFile];
657 | await downloadFile(
658 | file,
659 | setDecryptProgress,
660 | setDownloadProgress
661 | );
662 | }
663 | }}
664 | >
665 | Download all files
666 |
667 |
668 | )}
669 | {
673 | setShowShareBucketModal(false);
674 | }}
675 | header={
676 |
677 | Copy the link and share the bucket or just continue uploading. When
678 | you share the bucket draft (writeable), others can edit it.
679 |
680 | }
681 | shareLinkOnClick={closeShareBucketModal}
682 | shareDraftLinkOnClick={closeShareBucketModal}
683 | />
684 |
685 | );
686 | };
687 |
688 | export default Uploader;
689 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import { EncryptionType } from './models/encryption';
2 |
3 | export const SKYTRANSFER_BUCKET = 'SKYTRANSFER_BUCKET';
4 | export const BUCKET_PRIVATE_KEY_NAME = 'BUCKET_PRIVATE_KEY';
5 | export const BUCKET_ENCRYPTION_KEY_NAME = 'BUCKET_ENCRYPTION_KEY';
6 | export const MAX_PARALLEL_UPLOAD = 5;
7 | export const MAX_AXIOS_RETRIES = 3;
8 |
9 | // When the user uploads a tone of files to sync SkyDB periodically
10 | // in order to not lose the upload in case of errors.
11 | // The intervalled sync is executed when:
12 | // there are more than SKYDB_SYNC_FACTOR files to sync in SkyDB and
13 | // more than MIN_SKYDB_SYNC_FACTOR files to upload.
14 | export const SKYDB_SYNC_FACTOR = 10;
15 | export const MIN_SKYDB_SYNC_FACTOR = 5;
16 |
17 | export const DEFAULT_ENCRYPTION_TYPE = EncryptionType.Xchacha20poly1305_256MB;
18 | export const WEB_WORKER_URL = './webworker.bundle.js';
19 |
20 | export const TUS_CHUNK_SIZE = (1 << 22) * 10;
21 | /**
22 | * The retry delays, in ms. Data is stored in skyd for up to 20 minutes, so the
23 | * total delays should not exceed that length of time.
24 | */
25 | export const DEFAULT_TUS_RETRY_DELAYS = [0, 5000, 15000, 60000, 300000, 600000];
26 |
27 | export const DEFAULT_FILE_OBJECT_LIMIT = 805306368; // 768 MB
28 |
--------------------------------------------------------------------------------
/src/crypto/chunk-resolver.ts:
--------------------------------------------------------------------------------
1 | import { EncryptionType } from '../models/encryption';
2 |
3 | interface Encryption {
4 | encryptChunkSize: number;
5 | decryptChunkSize: number;
6 | encryptionType: EncryptionType;
7 | }
8 |
9 | const MBSize = 1048576;
10 |
11 | const encryptions: Encryption[] = [
12 | {
13 | encryptChunkSize: MBSize * 4,
14 | decryptChunkSize: MBSize * 4 + 17,
15 | encryptionType: EncryptionType.Xchacha20poly1305_4MB,
16 | },
17 | {
18 | encryptChunkSize: MBSize * 8,
19 | decryptChunkSize: MBSize * 8 + 17,
20 | encryptionType: EncryptionType.Xchacha20poly1305_8MB,
21 | },
22 | {
23 | encryptChunkSize: MBSize * 16,
24 | decryptChunkSize: MBSize * 16 + 17,
25 | encryptionType: EncryptionType.Xchacha20poly1305_16MB,
26 | },
27 | {
28 | encryptChunkSize: MBSize * 32,
29 | decryptChunkSize: MBSize * 32 + 17,
30 | encryptionType: EncryptionType.Xchacha20poly1305_32MB,
31 | },
32 | {
33 | encryptChunkSize: MBSize * 64,
34 | decryptChunkSize: MBSize * 64 + 17,
35 | encryptionType: EncryptionType.Xchacha20poly1305_64MB,
36 | },
37 | {
38 | encryptChunkSize: MBSize * 128,
39 | decryptChunkSize: MBSize * 128 + 17,
40 | encryptionType: EncryptionType.Xchacha20poly1305_128MB,
41 | },
42 | {
43 | encryptChunkSize: MBSize * 256,
44 | decryptChunkSize: MBSize * 256 + 17,
45 | encryptionType: EncryptionType.Xchacha20poly1305_256MB,
46 | },
47 | {
48 | encryptChunkSize: MBSize * 512,
49 | decryptChunkSize: MBSize * 512 + 17,
50 | encryptionType: EncryptionType.Xchacha20poly1305_512MB,
51 | },
52 | ];
53 |
54 | export class ChunkResolver {
55 | private encryptionType: EncryptionType;
56 |
57 | constructor(encryptionType: EncryptionType) {
58 | this.encryptionType = encryptionType;
59 | }
60 |
61 | get encryptChunkSize(): number {
62 | const found = encryptions.find(
63 | (e) => e.encryptionType === this.encryptionType
64 | ).encryptChunkSize;
65 | return found ? found : encryptions[0].encryptChunkSize;
66 | }
67 |
68 | get decryptChunkSize(): number {
69 | const found = encryptions.find(
70 | (e) => e.encryptionType === this.encryptionType
71 | ).decryptChunkSize;
72 | return found ? found : encryptions[0].decryptChunkSize;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/crypto/crypto.ts:
--------------------------------------------------------------------------------
1 | export interface FileEncoder {
2 | encryptChunkSize: number;
3 |
4 | encryptAndStream(): Promise;
5 |
6 | isStreamReady(): boolean;
7 |
8 | getStream(streamChunkSize: number): Promise;
9 |
10 | getStreamSize(): number;
11 | }
12 |
13 | export interface FileDecoder {
14 | decryptChunkSize: number;
15 |
16 | decrypt(): Promise;
17 | }
18 |
19 | export const publicKeyFromPrivateKey = (key: string): string => {
20 | return key.substr(key.length - 64);
21 | };
22 |
--------------------------------------------------------------------------------
/src/crypto/json.ts:
--------------------------------------------------------------------------------
1 | import CryptoJS from 'crypto-js';
2 |
3 | export class JsonCrypto {
4 | readonly encryptionKey: string;
5 |
6 | constructor(encryptionKey: string) {
7 | this.encryptionKey = encryptionKey;
8 | }
9 |
10 | public encrypt(json: any): string {
11 | return CryptoJS.AES.encrypt(
12 | JSON.stringify(json),
13 | this.encryptionKey
14 | ).toString();
15 | }
16 |
17 | public decrypt(encryptedJson: string): any {
18 | const decryptedMemories = CryptoJS.AES.decrypt(
19 | encryptedJson,
20 | this.encryptionKey
21 | ).toString(CryptoJS.enc.Utf8);
22 |
23 | return JSON.parse(decryptedMemories);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/crypto/xchacha20poly1305-decrypt.ts:
--------------------------------------------------------------------------------
1 | import { EncryptionType } from '../models/encryption';
2 | import { FileDecoder } from './crypto';
3 | import { ChunkResolver } from './chunk-resolver';
4 | import { IEncryptedFile } from '../models/files/encrypted-file';
5 |
6 | import _sodium from 'libsodium-wrappers';
7 | import axiosRetry from 'axios-retry';
8 | import axios from 'axios';
9 | import { MAX_AXIOS_RETRIES } from '../config';
10 |
11 | /*
12 | salt and header are appended to each file as Uint8Array
13 | salt is 16 bytes long
14 | header is 24 bytes long
15 | */
16 | const METADATA_SIZE = 40;
17 |
18 | export interface ICryptoMetadata {
19 | salt: Uint8Array;
20 | header: Uint8Array;
21 | }
22 |
23 | export default class Xchacha20poly1305Decrypt implements FileDecoder {
24 | parts: BlobPart[] = [];
25 | private encryptedFile: IEncryptedFile;
26 | private encryptionKey: string;
27 | private portalUrl: string;
28 | private chunkResolver: ChunkResolver;
29 | private stateIn: _sodium.StateAddress;
30 |
31 | constructor(encryptedFile: IEncryptedFile, portalUrl: string) {
32 | this.encryptedFile = encryptedFile;
33 | this.portalUrl = portalUrl;
34 | this.encryptionKey = encryptedFile.file.key;
35 | this.chunkResolver = new ChunkResolver(
36 | EncryptionType[
37 | encryptedFile.file.encryptionType as keyof typeof EncryptionType
38 | ]
39 | );
40 | }
41 |
42 | get decryptChunkSize(): number {
43 | return this.chunkResolver.decryptChunkSize;
44 | }
45 |
46 | async decrypt(
47 | onDecryptProgress: (
48 | completed: boolean,
49 | percentage: number
50 | ) => void = () => {},
51 | onFileDownloadProgress: (
52 | completed: boolean,
53 | percentage: number
54 | ) => void = () => {}
55 | ): Promise {
56 | await _sodium.ready;
57 | const sodium = _sodium;
58 |
59 | const cryptoMetadata = await this.cryptoMetadata();
60 |
61 | let key = sodium.crypto_pwhash(
62 | sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES,
63 | this.encryptionKey,
64 | cryptoMetadata.salt,
65 | sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
66 | sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
67 | sodium.crypto_pwhash_ALG_ARGON2ID13
68 | );
69 |
70 | this.stateIn = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
71 | cryptoMetadata.header,
72 | key
73 | );
74 |
75 | const totalChunks = Math.ceil(
76 | (this.encryptedFile.file.encryptedSize - METADATA_SIZE) /
77 | this.decryptChunkSize
78 | );
79 |
80 | let rangeEnd = 0;
81 | let index = METADATA_SIZE;
82 |
83 | onFileDownloadProgress(false, 0.1);
84 |
85 | for (let i = 0; i < totalChunks; i++) {
86 | if (i === totalChunks - 1) {
87 | rangeEnd = this.encryptedFile.file.encryptedSize;
88 | } else {
89 | rangeEnd = index + this.decryptChunkSize - 1; // -1 because rangeEnd is included
90 | }
91 |
92 | const bytesRange = `bytes=${index}-${rangeEnd}`;
93 |
94 | const url = this.encryptedFile.file.url;
95 |
96 | try {
97 | const response = await this.downloadFile(
98 | url,
99 | (progressEvent) => {
100 | const progress = Math.round(
101 | (progressEvent.loaded / progressEvent.total) * 100
102 | );
103 | onFileDownloadProgress(false, progress);
104 | },
105 | bytesRange
106 | );
107 |
108 | const progress = Math.floor(((i + 1) / totalChunks) * 100);
109 | onDecryptProgress(false, progress);
110 |
111 | const data: Blob = response.data;
112 | const buff = await data.arrayBuffer();
113 | const chunkPart = await this.decryptBlob(buff, sodium);
114 | this.parts.push(chunkPart);
115 |
116 | index += this.decryptChunkSize;
117 | } catch (error) {
118 | onFileDownloadProgress(true, 0);
119 | throw new Error(
120 | `could not download the file because of: ${error.message}`
121 | );
122 | }
123 | }
124 |
125 | onDecryptProgress(true, 100);
126 |
127 | return new File(this.parts, this.encryptedFile.name, {
128 | type: this.encryptedFile.mimeType,
129 | });
130 | }
131 |
132 | private async cryptoMetadata(): Promise {
133 | const headerAndSaltBytesRange = `bytes=${0}-${39}`;
134 | const response = await this.downloadFile(
135 | this.encryptedFile.file.url,
136 | () => {},
137 | headerAndSaltBytesRange
138 | );
139 |
140 | const data: Blob = response.data;
141 |
142 | const [salt, header] = await Promise.all([
143 | data.slice(0, 16).arrayBuffer(), //salt
144 | data.slice(16, 40).arrayBuffer(), //header
145 | ]);
146 |
147 | let uSalt = new Uint8Array(salt);
148 | let uHeader = new Uint8Array(header);
149 |
150 | return {
151 | salt: uSalt,
152 | header: uHeader,
153 | };
154 | }
155 |
156 | private async downloadFile(skylink: string, onProgress, bytesRange?: string) {
157 | axiosRetry(axios, {
158 | retries: MAX_AXIOS_RETRIES,
159 | retryCondition: (_e) => true, // retry no matter what
160 | });
161 |
162 | const url = skylink.replace('sia://', `${this.portalUrl}/`);
163 |
164 | return axios({
165 | method: 'get',
166 | url: url,
167 | headers: {
168 | Range: bytesRange,
169 | },
170 | responseType: 'blob',
171 | onDownloadProgress: onProgress,
172 | withCredentials: true,
173 | });
174 | }
175 |
176 | private async decryptBlob(
177 | chunk: ArrayBuffer,
178 | sodium: typeof _sodium
179 | ): Promise {
180 | const result = sodium.crypto_secretstream_xchacha20poly1305_pull(
181 | this.stateIn,
182 | new Uint8Array(chunk)
183 | );
184 |
185 | if (!result) {
186 | throw Error('error during xchacha20poly1305 decryption');
187 | }
188 |
189 | return result.message;
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/src/crypto/xchacha20poly1305-encrypt.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_ENCRYPTION_TYPE } from '../config';
2 | import { ChunkResolver } from './chunk-resolver';
3 | import { FileEncoder } from './crypto';
4 | import _sodium from 'libsodium-wrappers';
5 | import { v4 as uuid } from 'uuid';
6 |
7 | export default class Xchacha20poly1305Encrypt implements FileEncoder {
8 | readonly encryptionKey: string;
9 | readonly totalChunks: number = 0;
10 | fileChunks: BlobPart[] = [];
11 | private file: File;
12 | private chunkResolver: ChunkResolver;
13 | private stateOut: _sodium.StateAddress;
14 | private isStreamReadyToBeConsumed: boolean = false;
15 | private streamSize: number = 0;
16 | private chunkCounter = 0;
17 |
18 | constructor(file: File, encryptionKey: string) {
19 | this.file = file;
20 | this.encryptionKey = encryptionKey;
21 | this.chunkResolver = new ChunkResolver(DEFAULT_ENCRYPTION_TYPE);
22 | this.totalChunks = Math.ceil(this.file.size / this.encryptChunkSize);
23 | }
24 |
25 | get encryptChunkSize(): number {
26 | return this.chunkResolver.encryptChunkSize;
27 | }
28 |
29 | async encryptAndStream(
30 | onEncryptProgress: (
31 | completed: boolean,
32 | percentage: number
33 | ) => void = () => {}
34 | ): Promise {
35 | await _sodium.ready;
36 | const sodium = _sodium;
37 | const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
38 |
39 | const key = sodium.crypto_pwhash(
40 | sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES,
41 | this.encryptionKey,
42 | salt,
43 | sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
44 | sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
45 | sodium.crypto_pwhash_ALG_ARGON2ID13
46 | );
47 |
48 | let res = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
49 | let [state_out, header] = [res.state, res.header];
50 | this.stateOut = state_out;
51 |
52 | this.fileChunks.push(salt);
53 | this.fileChunks.push(header);
54 |
55 | this.streamSize += salt.byteLength;
56 | this.streamSize += header.byteLength;
57 |
58 | onEncryptProgress(false, 1);
59 |
60 | while (this.hasNextChunkDelimiter()) {
61 | const delimiters = this.nextChunkDelimiters();
62 | const buffer = await this.file
63 | .slice(delimiters[0], delimiters[1])
64 | .arrayBuffer();
65 |
66 | const encryptedChunk = await this.encryptBlob(
67 | buffer,
68 | sodium,
69 | !this.hasNextChunkDelimiter()
70 | );
71 |
72 | this.fileChunks.push(encryptedChunk);
73 | this.streamSize += encryptedChunk.byteLength;
74 | onEncryptProgress(false, this.progress());
75 | }
76 |
77 | onEncryptProgress(true, 100);
78 |
79 | this.isStreamReadyToBeConsumed = true;
80 | }
81 |
82 | isStreamReady(): boolean {
83 | return this.isStreamReadyToBeConsumed;
84 | }
85 |
86 | async getStream(streamChunkSize: number): Promise {
87 | if (streamChunkSize > this.chunkResolver.encryptChunkSize) {
88 | throw new Error(
89 | 'streamChunkSize should be less or equal to encryptChunkSize'
90 | );
91 | }
92 |
93 | if (!this.isStreamReady()) {
94 | throw new Error('stream is not ready');
95 | }
96 |
97 | const file = new File(this.fileChunks, `skytransfer-${uuid()}`, {
98 | type: 'text/plain',
99 | });
100 |
101 | const totalChunks = Math.ceil(this.streamSize / streamChunkSize);
102 | let streamCounter = 0;
103 | let endStream: boolean;
104 |
105 | const that = this;
106 |
107 | function getEndDelimiterAndSetEndStream(): number {
108 | let endDelimiter;
109 |
110 | if (streamCounter === totalChunks - 1) {
111 | endDelimiter = that.streamSize;
112 | endStream = true;
113 | } else {
114 | endDelimiter = (streamCounter + 1) * streamChunkSize;
115 | }
116 |
117 | return endDelimiter;
118 | }
119 |
120 | return new ReadableStream({
121 | async start(controller) {
122 | controller.enqueue(
123 | file.slice(
124 | streamChunkSize * streamCounter,
125 | getEndDelimiterAndSetEndStream()
126 | )
127 | );
128 | streamCounter++;
129 | },
130 | async pull(controller) {
131 | controller.enqueue(
132 | file.slice(
133 | streamChunkSize * streamCounter,
134 | getEndDelimiterAndSetEndStream()
135 | )
136 | );
137 | streamCounter++;
138 |
139 | if (endStream) {
140 | controller.close();
141 | return;
142 | }
143 | },
144 | });
145 | }
146 |
147 | getStreamSize(): number {
148 | return this.streamSize;
149 | }
150 |
151 | private progress(): number {
152 | if (this.chunkCounter === 0) {
153 | return 0;
154 | }
155 |
156 | return Math.floor((this.chunkCounter / this.totalChunks) * 100);
157 | }
158 |
159 | private nextChunkDelimiters(): number[] {
160 | const startDelimiter = this.chunkCounter * this.encryptChunkSize;
161 | let endDelimiter: number;
162 |
163 | if (this.chunkCounter === this.totalChunks - 1) {
164 | endDelimiter = this.file.size;
165 | } else {
166 | endDelimiter = (this.chunkCounter + 1) * this.encryptChunkSize;
167 | }
168 |
169 | this.chunkCounter++;
170 |
171 | return [startDelimiter, endDelimiter];
172 | }
173 |
174 | private hasNextChunkDelimiter(): boolean {
175 | return this.chunkCounter <= this.totalChunks - 1;
176 | }
177 |
178 | private async encryptBlob(
179 | chunk: ArrayBuffer,
180 | sodium: typeof _sodium,
181 | last: boolean = false
182 | ): Promise {
183 | let tag = last
184 | ? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
185 | : sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
186 |
187 | return sodium.crypto_secretstream_xchacha20poly1305_push(
188 | this.stateOut,
189 | new Uint8Array(chunk),
190 | null,
191 | tag
192 | );
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/src/features/bucket/bucket-slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import SessionManager from '../../session/session-manager';
3 |
4 | export interface IBucketState {
5 | privateKey: string;
6 | encryptionKey: string;
7 |
8 | bucketIsLoading: boolean;
9 | }
10 |
11 | interface IActiveBucketKeys {
12 | bucketPrivateKey: string;
13 | bucketEncryptionKey: string;
14 | }
15 |
16 | const initialState: IBucketState = {
17 | privateKey: null,
18 | encryptionKey: null,
19 | bucketIsLoading: false,
20 | };
21 |
22 | export const bucketSlice = createSlice({
23 | name: 'bucket',
24 | initialState: initialState,
25 | reducers: {
26 | keySet: (state, action: PayloadAction) => {
27 | state.privateKey = action.payload.bucketPrivateKey;
28 | state.encryptionKey = action.payload.bucketEncryptionKey;
29 | },
30 | bucketIsLoadingStart: (state, action: PayloadAction) => {
31 | state.bucketIsLoading = true;
32 | },
33 | bucketIsLoadingFinish: (state, action: PayloadAction) => {
34 | state.bucketIsLoading = false;
35 | },
36 | },
37 | });
38 |
39 | export const { keySet, bucketIsLoadingStart, bucketIsLoadingFinish } =
40 | bucketSlice.actions;
41 |
42 | export default bucketSlice.reducer;
43 |
44 | export const initUserKeys = ({
45 | bucketPrivateKey,
46 | bucketEncryptionKey,
47 | }: IActiveBucketKeys) => {
48 | return async (dispatch, getState) => {
49 | dispatch(keySet({ bucketPrivateKey, bucketEncryptionKey }));
50 | };
51 | };
52 |
53 | export const setUserKeys = ({
54 | bucketPrivateKey,
55 | bucketEncryptionKey,
56 | }: IActiveBucketKeys) => {
57 | return async (dispatch, getState) => {
58 | SessionManager.setSessionKeys({
59 | bucketPrivateKey,
60 | bucketEncryptionKey,
61 | });
62 | dispatch(keySet({ bucketPrivateKey, bucketEncryptionKey }));
63 | };
64 | };
65 |
66 | // The function below is called a selector and allows us to select a value from
67 | // the state. Selectors can also be defined inline where they're used instead of
68 | // in the slice file. For example: `useSelector((state) => state.counter.value)`
69 | export const selectBucket = (state) => state.bucket;
70 |
--------------------------------------------------------------------------------
/src/features/user/user-slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { MySky } from 'skynet-js';
3 | import { IUser, IUserState, UserStatus } from '../../models/user';
4 | import {
5 | deleteUserReadOnlyHiddenBucket,
6 | deleteUserReadWriteHiddenBucket,
7 | getAllUserHiddenBuckets,
8 | getMySky,
9 | storeUserReadOnlyHiddenBucket,
10 | storeUserReadWriteHiddenBucket,
11 | } from '../../skynet/skynet';
12 | import {
13 | IBucketsInfo,
14 | IReadOnlyBucketInfo,
15 | IReadWriteBucketInfo,
16 | } from '../../models/files/bucket';
17 | import {
18 | bucketIsLoadingFinish,
19 | bucketIsLoadingStart,
20 | } from '../bucket/bucket-slice';
21 |
22 | const initialState: IUserState = {
23 | status: UserStatus.NotLogged,
24 | data: null,
25 |
26 | buckets: {
27 | readOnly: {},
28 | readWrite: {},
29 | },
30 | };
31 |
32 | export const userSlice = createSlice({
33 | name: 'user',
34 | initialState: initialState,
35 | reducers: {
36 | logout: (state) => {
37 | // TODO
38 | },
39 | userLoading: (state, action: PayloadAction) => {
40 | state.status = UserStatus.Loading;
41 | },
42 | userLoadingFailed: (state, action: PayloadAction) => {
43 | state.status = UserStatus.NotLogged;
44 | },
45 | userLoaded: (state, action: PayloadAction) => {
46 | state.data = action.payload;
47 | state.status = UserStatus.Logged;
48 | },
49 | bucketsSet: (state, action: PayloadAction) => {
50 | state.buckets.readOnly = action.payload.readOnly;
51 | state.buckets.readWrite = action.payload.readWrite;
52 | },
53 | readWriteBucketRemoved: (
54 | state,
55 | action: PayloadAction<{ bucketID: string }>
56 | ) => {
57 | const newState = { ...state.buckets.readWrite };
58 | delete newState[action.payload.bucketID];
59 | state.buckets.readWrite = newState;
60 | },
61 | readOnlyBucketRemoved: (
62 | state,
63 | action: PayloadAction<{ bucketID: string }>
64 | ) => {
65 | const newState = { ...state.buckets.readOnly };
66 | delete newState[action.payload.bucketID];
67 | state.buckets.readOnly = newState;
68 | },
69 | readWriteBucketAdded: (
70 | state,
71 | action: PayloadAction
72 | ) => {
73 | const newState = { ...state.buckets.readWrite };
74 | newState[action.payload.bucketID] = action.payload;
75 | state.buckets.readWrite = { ...newState };
76 | },
77 | readOnlyBucketAdded: (
78 | state,
79 | action: PayloadAction
80 | ) => {
81 | const newState = { ...state.buckets.readOnly };
82 | newState[action.payload.bucketID] = action.payload;
83 | state.buckets.readOnly = { ...newState };
84 | },
85 | },
86 | });
87 |
88 | export const {
89 | logout,
90 | userLoaded,
91 | bucketsSet,
92 | readWriteBucketRemoved,
93 | readOnlyBucketRemoved,
94 | readWriteBucketAdded,
95 | readOnlyBucketAdded,
96 | userLoading,
97 | userLoadingFailed,
98 | } = userSlice.actions;
99 |
100 | export default userSlice.reducer;
101 |
102 | const performLogin = async (dispatch, mySky: MySky) => {
103 | // const userProfileRecord = new UserProfileDAC();
104 | //
105 | // // @ts-ignore
106 | // await mySky.loadDacs(userProfileRecord);
107 |
108 | // @ts-ignore
109 | // const userProfile = await userProfileRecord.getProfile(userID);
110 |
111 | // const tempUser: IUser = {
112 | // username: userProfile.username,
113 | // description: userProfile.description,
114 | // avatar: null,
115 | // };
116 |
117 | // if (userProfile.avatar && userProfile.avatar.length > 0) {
118 | // const avatarPrefix = getCurrentPortal().domain;
119 | // tempUser['avatar'] = userProfile.avatar[0].url.replace(
120 | // 'sia://',
121 | // `https://${avatarPrefix}/`
122 | // );
123 | // }
124 |
125 | await mySky.userID();
126 |
127 | // TODO: uncomment the code above once the UserProfileDAC is fixed!
128 | const tempUser: IUser = {
129 | username: 'Welcome!',
130 | description: '',
131 | avatar: null,
132 | };
133 |
134 | dispatch(userLoaded(tempUser));
135 | dispatch(loadBuckets(mySky));
136 | };
137 |
138 | export const silentLogin = () => {
139 | return async (dispatch, getState) => {
140 | dispatch(userLoading());
141 | try {
142 | const mySky = await getMySky();
143 | const loggedIn = await mySky.checkLogin();
144 | if (!loggedIn) {
145 | dispatch(userLoadingFailed());
146 | return;
147 | }
148 |
149 | await performLogin(dispatch, mySky);
150 | } catch (err) {
151 | console.error(err);
152 | dispatch(userLoadingFailed());
153 | }
154 | };
155 | };
156 |
157 | export const login = () => {
158 | return async (dispatch, getState) => {
159 | dispatch(userLoading());
160 | try {
161 | const mySky = await getMySky();
162 | if (!(await mySky.requestLoginAccess())) {
163 | dispatch(userLoadingFailed());
164 | throw Error('could not login');
165 | }
166 |
167 | await performLogin(dispatch, mySky);
168 | } catch (err) {
169 | dispatch(userLoadingFailed());
170 | console.error(err);
171 | }
172 | };
173 | };
174 |
175 | export const loadBuckets = (mySky: MySky) => {
176 | return async (dispatch, getState) => {
177 | dispatch(bucketIsLoadingStart());
178 | const { readOnly, readWrite } = await getAllUserHiddenBuckets(mySky);
179 | dispatch(bucketsSet({ readOnly, readWrite }));
180 | dispatch(bucketIsLoadingFinish());
181 | };
182 | };
183 |
184 | export const deleteReadWriteBucket = (mySky: MySky, bucketID: string) => {
185 | return async (dispatch, getState) => {
186 | dispatch(bucketIsLoadingStart());
187 | await deleteUserReadWriteHiddenBucket(mySky, bucketID);
188 | dispatch(readWriteBucketRemoved({ bucketID }));
189 | dispatch(bucketIsLoadingFinish());
190 | };
191 | };
192 |
193 | export const deleteReadOnlyBucket = (mySky: MySky, bucketID: string) => {
194 | return async (dispatch, getState) => {
195 | dispatch(bucketIsLoadingStart());
196 | await deleteUserReadOnlyHiddenBucket(mySky, bucketID);
197 | dispatch(readOnlyBucketRemoved({ bucketID }));
198 | dispatch(bucketIsLoadingFinish());
199 | };
200 | };
201 |
202 | export const addReadWriteBucket = (
203 | mySky: MySky,
204 | bucket: IReadWriteBucketInfo
205 | ) => {
206 | return async (dispatch, getState) => {
207 | dispatch(bucketIsLoadingStart());
208 | await storeUserReadWriteHiddenBucket(mySky, bucket);
209 | dispatch(readWriteBucketAdded(bucket));
210 | dispatch(bucketIsLoadingFinish());
211 | };
212 | };
213 |
214 | export const addReadOnlyBucket = (
215 | mySky: MySky,
216 | bucket: IReadOnlyBucketInfo
217 | ) => {
218 | return async (dispatch, getState) => {
219 | dispatch(bucketIsLoadingStart());
220 | await storeUserReadOnlyHiddenBucket(mySky, bucket);
221 | dispatch(readOnlyBucketAdded(bucket));
222 | dispatch(bucketIsLoadingFinish());
223 | };
224 | };
225 |
226 | export const selectUser = (state) => state.user;
227 |
--------------------------------------------------------------------------------
/src/features/user/user.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { UserStatus } from '../../models/user';
3 |
4 | import { Avatar, Card } from 'antd';
5 | import { EditOutlined } from '@ant-design/icons';
6 |
7 | import { selectUser } from './user-slice';
8 |
9 | const { Meta } = Card;
10 |
11 | export function User() {
12 | const user = useSelector(selectUser);
13 |
14 | return (
15 | <>
16 | {user.status === UserStatus.Logged ? (
17 |
24 | Edit profile
25 | ,
26 | ]}
27 | >
28 | }
30 | title={user.data.username}
31 | />
32 |
33 | ) : (
34 |
41 | Edit profile
42 | ,
43 | ]}
44 | >
45 | } title="Anonimous" />
46 |
47 | )}
48 | >
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/images/bucket.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
--------------------------------------------------------------------------------
/src/images/change-portal.svg:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
7 |
9 |
11 |
13 |
14 |
17 |
18 |
--------------------------------------------------------------------------------
/src/images/homescreen.svg:
--------------------------------------------------------------------------------
1 |
3 | SKYNET: ADD TO HOMESCREEN
4 |
5 |
6 |
7 |
8 |
10 |
12 | SKYNET
13 | ADD TO
14 | HOMESCREEN
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/images/skytransfer-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
7 |
9 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/images/smile.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
7 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/images/support.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
7 |
9 |
11 |
13 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import 'react-app-polyfill/stable';
2 |
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import './index.css';
6 | import App from './app';
7 |
8 | import store from './app/store';
9 | import { Provider } from 'react-redux';
10 | import SessionManager from './session/session-manager';
11 | import { silentLogin } from './features/user/user-slice';
12 | import { initUserKeys } from './features/bucket/bucket-slice';
13 |
14 | const { bucketPrivateKey, bucketEncryptionKey } = SessionManager.sessionKeys;
15 |
16 | store.dispatch(initUserKeys({ bucketPrivateKey, bucketEncryptionKey }));
17 | store.dispatch(silentLogin());
18 |
19 | ReactDOM.render(
20 |
21 |
22 |
23 |
24 | ,
25 | ,
26 | document.getElementById('root')
27 | );
28 |
--------------------------------------------------------------------------------
/src/models/encryption.ts:
--------------------------------------------------------------------------------
1 | export enum EncryptionType {
2 | Xchacha20poly1305_4MB,
3 | Xchacha20poly1305_8MB,
4 | Xchacha20poly1305_16MB,
5 | Xchacha20poly1305_32MB,
6 | Xchacha20poly1305_64MB,
7 | Xchacha20poly1305_128MB,
8 | Xchacha20poly1305_256MB,
9 | Xchacha20poly1305_512MB,
10 | }
11 |
12 | export interface IEncryptionReaderResult {
13 | value: BlobPart;
14 | done: boolean;
15 | }
16 |
--------------------------------------------------------------------------------
/src/models/file-tree.ts:
--------------------------------------------------------------------------------
1 | export interface IFileNode {
2 | title: string;
3 | key: string;
4 | children?: IFileNode[];
5 | isLeaf: boolean;
6 | }
7 |
--------------------------------------------------------------------------------
/src/models/files/bucket.ts:
--------------------------------------------------------------------------------
1 | import { IEncryptedFile, IEncryptedFiles } from './encrypted-file';
2 |
3 | export interface IBucket {
4 | uuid: string;
5 | name: string;
6 | description: string;
7 | files: IEncryptedFiles;
8 | created: number;
9 | modified: number;
10 | }
11 |
12 | export interface IReadWriteBucketInfo {
13 | bucketID: string;
14 | privateKey: string;
15 | encryptionKey: string;
16 | }
17 |
18 | export interface IReadOnlyBucketInfo {
19 | bucketID: string;
20 | publicKey: string;
21 | encryptionKey: string;
22 | }
23 |
24 | export interface IReadWriteBucketsInfo {
25 | [bucketID: string]: IReadWriteBucketInfo;
26 | }
27 |
28 | export interface IReadOnlyBucketsInfo {
29 | [bucketID: string]: IReadOnlyBucketInfo;
30 | }
31 |
32 | export interface IBucketsInfo {
33 | readWrite: IReadWriteBucketsInfo;
34 | readOnly: IReadOnlyBucketsInfo;
35 | }
36 |
37 | export interface IBuckets {
38 | [bucketID: string]: IBucket;
39 | }
40 |
41 | export interface IAllBuckets {
42 | readOnly: IBuckets;
43 | readWrite: IBuckets;
44 | }
45 |
46 | export interface DecryptedBucket extends IBucket {}
47 |
48 | export class DecryptedBucket {
49 | constructor(bucket: IBucket) {
50 | Object.assign(this, bucket, {});
51 | }
52 |
53 | encryptedFileExists(relativePath: string): boolean {
54 | return this.files && relativePath in this.files;
55 | }
56 |
57 | getEncryptedFile(relativePath: string): IEncryptedFile {
58 | if (this.encryptedFileExists(relativePath)) {
59 | return this.files[relativePath];
60 | }
61 |
62 | throw Error("file doesn't exist");
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/models/files/encrypted-file.ts:
--------------------------------------------------------------------------------
1 | import { IFileData } from './file-data';
2 |
3 | export interface IEncryptedFiles {
4 | [fileRelativePath: string]: IEncryptedFile;
5 | }
6 |
7 | export class IEncryptedFile {
8 | uuid: string;
9 | file: IFileData;
10 | created: number;
11 | name: string;
12 | modified: number;
13 | mimeType: string;
14 | history: IFileData[];
15 | version: number;
16 | }
17 |
--------------------------------------------------------------------------------
/src/models/files/file-data.ts:
--------------------------------------------------------------------------------
1 | export class IFileData {
2 | uuid: string;
3 | size: number;
4 | chunkSize: number;
5 | encryptionType: string;
6 | url: string;
7 | key: string;
8 | hash: string;
9 | ts: number;
10 | encryptedSize: number;
11 | relativePath: string;
12 | }
13 |
--------------------------------------------------------------------------------
/src/models/session.ts:
--------------------------------------------------------------------------------
1 | export interface Session {
2 | id: string;
3 | name: string;
4 | key: string;
5 | createdAt: number; // timestamp
6 | }
7 |
8 | export interface PublicSession {
9 | id: string;
10 | name: string;
11 | link: string;
12 | createdAt: number; // timestamp
13 | }
14 |
--------------------------------------------------------------------------------
/src/models/user.ts:
--------------------------------------------------------------------------------
1 | import { IBucketsInfo } from './files/bucket';
2 |
3 | export interface IUser {
4 | username: string;
5 | avatar: string;
6 | description: string;
7 | }
8 |
9 | export enum UserStatus {
10 | NotLogged = 1,
11 | Loading,
12 | Logged,
13 | }
14 |
15 | export interface IUserState {
16 | status: UserStatus;
17 | data: IUser;
18 | buckets: IBucketsInfo;
19 | }
20 |
--------------------------------------------------------------------------------
/src/portals.test.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 | import { getPortals, Portal } from './portals';
3 |
4 | describe('Portals', () => {
5 | describe('getPortals()', () => {
6 | test('returns correct values', () => {
7 | const expected: Portal[] = [
8 | {
9 | domain: 'siasky.net',
10 | displayName: 'siasky.net',
11 | },
12 | {
13 | domain: 'fileportal.org',
14 | displayName: 'fileportal.org',
15 | },
16 | {
17 | domain: 'skynetfree.net',
18 | displayName: 'skynetfree.net',
19 | },
20 | {
21 | domain: 'skynetpro.net',
22 | displayName: 'skynetpro.net',
23 | },
24 | ];
25 | const result = getPortals();
26 |
27 | expect(result).toEqual(expected);
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/portals.ts:
--------------------------------------------------------------------------------
1 | export interface Portal {
2 | domain: string;
3 | displayName: string;
4 | }
5 |
6 | const knownPortals: readonly Portal[] = [
7 | {
8 | domain: 'siasky.net',
9 | displayName: 'siasky.net',
10 | },
11 | {
12 | domain: 'fileportal.org',
13 | displayName: 'fileportal.org',
14 | },
15 | {
16 | domain: 'skynetfree.net',
17 | displayName: 'skynetfree.net',
18 | },
19 | {
20 | domain: 'skynetpro.net',
21 | displayName: 'skynetpro.net',
22 | },
23 | ];
24 |
25 | export const getDefaultPortal = (): Portal => {
26 | return knownPortals[0];
27 | };
28 |
29 | export const getCurrentPortal = (): Portal => {
30 | const portalDomain = window.location.hostname.replace('skytransfer.hns.', '');
31 |
32 | for (let portal of knownPortals) {
33 | if (portal.domain === portalDomain) {
34 | return portal;
35 | }
36 | }
37 |
38 | return getDefaultPortal();
39 | };
40 |
41 | export const getTusUploadEndpoint = (): string => {
42 | return `https://${getCurrentPortal().domain}/skynet/tus`;
43 | };
44 |
45 | export const getEndpointInCurrentPortal = (): string => {
46 | return `https://${getCurrentPortal().domain}`;
47 | };
48 |
49 | export const getPortals = (): readonly Portal[] => knownPortals;
50 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/session/session-manager.ts:
--------------------------------------------------------------------------------
1 | import { genKeyPairAndSeed } from 'skynet-js';
2 | import { BUCKET_ENCRYPTION_KEY_NAME, BUCKET_PRIVATE_KEY_NAME } from '../config';
3 | import { publicKeyFromPrivateKey } from '../crypto/crypto';
4 |
5 | export interface ISessionManagerKeys {
6 | bucketPrivateKey: string;
7 | bucketEncryptionKey: string;
8 | }
9 |
10 | export default class SessionManager {
11 | static get sessionKeys(): ISessionManagerKeys {
12 | let bucketPrivateKey = localStorage.getItem(BUCKET_PRIVATE_KEY_NAME);
13 | let bucketEncryptionKey = localStorage.getItem(BUCKET_ENCRYPTION_KEY_NAME);
14 |
15 | if (!bucketPrivateKey || !bucketEncryptionKey) {
16 | bucketPrivateKey = genKeyPairAndSeed().privateKey;
17 | localStorage.setItem(BUCKET_PRIVATE_KEY_NAME, bucketPrivateKey);
18 |
19 | bucketEncryptionKey = genKeyPairAndSeed().privateKey;
20 | localStorage.setItem(BUCKET_ENCRYPTION_KEY_NAME, bucketEncryptionKey);
21 | }
22 |
23 | return {
24 | bucketPrivateKey,
25 | bucketEncryptionKey,
26 | };
27 | }
28 |
29 | static get readOnlyLink() {
30 | const { bucketPrivateKey, bucketEncryptionKey } = this.sessionKeys;
31 |
32 | if (this.isReadOnlyFromLink()) {
33 | return `https://${window.location.hostname}/${window.location.hash}`;
34 | }
35 | return `https://${window.location.hostname}/#/v2/${publicKeyFromPrivateKey(
36 | bucketPrivateKey
37 | )}/${bucketEncryptionKey}`;
38 | }
39 |
40 | static get readWriteLink() {
41 | const { bucketPrivateKey, bucketEncryptionKey } = this.sessionKeys;
42 |
43 | return `https://${window.location.hostname}/#/v2/${bucketPrivateKey}/${bucketEncryptionKey}`;
44 | }
45 |
46 | static setSessionKeys(sessionKeys: ISessionManagerKeys) {
47 | localStorage.setItem(BUCKET_PRIVATE_KEY_NAME, sessionKeys.bucketPrivateKey);
48 | localStorage.setItem(
49 | BUCKET_ENCRYPTION_KEY_NAME,
50 | sessionKeys.bucketEncryptionKey
51 | );
52 | }
53 |
54 | static destroySession() {
55 | localStorage.removeItem(BUCKET_PRIVATE_KEY_NAME);
56 | }
57 |
58 | static isReadOnlyFromLink() {
59 | return (
60 | window.location.hash.split('/').length === 4 &&
61 | window.location.hash.split('/')[2].length === 64
62 | );
63 | }
64 |
65 | static canResume() {
66 | return localStorage.getItem(BUCKET_PRIVATE_KEY_NAME) !== null;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/skynet/skynet.ts:
--------------------------------------------------------------------------------
1 | import { getEndpointInCurrentPortal, getTusUploadEndpoint } from './../portals';
2 | import { MySky, SkynetClient } from 'skynet-js';
3 | import {
4 | DEFAULT_FILE_OBJECT_LIMIT,
5 | DEFAULT_TUS_RETRY_DELAYS,
6 | SKYTRANSFER_BUCKET,
7 | TUS_CHUNK_SIZE,
8 | } from '../config';
9 | import { JsonCrypto } from '../crypto/json';
10 | import {
11 | IAllBuckets,
12 | IBucket,
13 | IBuckets,
14 | IBucketsInfo,
15 | IReadOnlyBucketInfo,
16 | IReadOnlyBucketsInfo,
17 | IReadWriteBucketInfo,
18 | IReadWriteBucketsInfo,
19 | } from '../models/files/bucket';
20 | import { publicKeyFromPrivateKey } from '../crypto/crypto';
21 | import * as tus from 'tus-js-client';
22 | import { v4 as uuid } from 'uuid';
23 | import axios from 'axios';
24 | import { fromStreamToFile } from '../utils/utils';
25 |
26 | const skynetClient = new SkynetClient(getEndpointInCurrentPortal());
27 |
28 | const dataDomain = 'skytransfer.hns';
29 | const privateReadWriteUserBucketsPath = 'skytransfer.hns/userBuckets.json';
30 | const privateReadOnlyUserBucketsPath =
31 | 'skytransfer.hns/userReadOnlyBuckets.json';
32 |
33 | export async function uploadFileFromStream(
34 | fileKey: string,
35 | uploadSize: number,
36 | fileReader: ReadableStream,
37 | onProgress,
38 | onSuccess,
39 | onError
40 | ) {
41 | const tempFilename = `skytransfer-${uuid()}`;
42 |
43 | if (uploadSize <= DEFAULT_FILE_OBJECT_LIMIT) {
44 | const fileToUpload = await fromStreamToFile(fileReader, tempFilename);
45 | return uploadFile(fileToUpload, fileKey, onProgress, onSuccess, onError);
46 | }
47 |
48 | const onProgressTus = function (bytesSent, bytesTotal) {
49 | const progress = bytesSent / bytesTotal;
50 | onProgress({ percent: Math.floor(progress * 100) });
51 | };
52 |
53 | return new Promise((resolve, reject) => {
54 | const reader = fileReader.getReader();
55 | const headers = {};
56 |
57 | const upload = new tus.Upload(reader, {
58 | endpoint: getTusUploadEndpoint(),
59 | chunkSize: TUS_CHUNK_SIZE,
60 | retryDelays: DEFAULT_TUS_RETRY_DELAYS,
61 | metadata: {
62 | filename: tempFilename,
63 | filetype: 'text/plain',
64 | },
65 | onProgress: onProgressTus,
66 | onChunkComplete: (s, b) => {
67 | console.log('[tus upload] -> chunk completed!');
68 | console.log(`[tus upload] -> chunk size ${s}`);
69 | console.log(`[tus upload] -> bytes accepted ${b}`);
70 | },
71 | onBeforeRequest: function (req) {
72 | const xhr = req.getUnderlyingObject();
73 | xhr.withCredentials = true;
74 | },
75 | onError: (error) => {
76 | reject(error);
77 | },
78 | headers,
79 | onSuccess: async () => {
80 | if (!upload.url) {
81 | reject(new Error("'upload.url' was not set"));
82 | return;
83 | }
84 |
85 | await propagateMetadata(fileKey, upload, onSuccess, onError);
86 | resolve(upload);
87 | },
88 | uploadSize,
89 | });
90 |
91 | upload.start();
92 | });
93 | }
94 |
95 | const propagateMetadata = async (fileKey, upload, onSuccess, onError) => {
96 | try {
97 | const resp = await axios.head(upload.url, {
98 | headers: {
99 | 'Tus-Resumable': '1.0.0',
100 | },
101 | });
102 | if (!resp.headers) {
103 | onError(new Error('response.headers field missing'));
104 | }
105 |
106 | onSuccess({
107 | skylink: `sia://${resp.headers['skynet-skylink']}`,
108 | encryptedFileSize: resp.headers['upload-length'],
109 | fileKey: fileKey,
110 | });
111 | } catch (err) {
112 | onError(
113 | new Error(
114 | `Did not get a complete upload response despite a successful request. Please try again and report this issue to the devs if it persists. Error: ${err}`
115 | )
116 | );
117 | }
118 | };
119 |
120 | export const uploadFile = async (
121 | encryptedFile: File,
122 | fileKey: string,
123 | onProgress,
124 | onSuccess,
125 | onError
126 | ) => {
127 | try {
128 | const { skylink } = await skynetClient.uploadFile(encryptedFile, {
129 | onUploadProgress: (p) => {
130 | onProgress({ percent: Math.floor(p * 100) }, encryptedFile);
131 | },
132 | });
133 |
134 | onSuccess({
135 | skylink: skylink,
136 | encryptedFileSize: encryptedFile.size,
137 | fileKey: fileKey,
138 | });
139 | } catch (e) {
140 | onError(new Error(`Could not complete upload: ${e}`));
141 | }
142 | };
143 |
144 | export const encryptAndStoreBucket = async (
145 | privateKey: string,
146 | encryptionKey: string,
147 | bucket: IBucket
148 | ): Promise => {
149 | return new Promise(async (resolve, reject) => {
150 | const jsonCrypto = new JsonCrypto(encryptionKey);
151 | const encryptedBucket = jsonCrypto.encrypt(bucket);
152 |
153 | try {
154 | await skynetClient.db.setJSON(privateKey, SKYTRANSFER_BUCKET, {
155 | data: encryptedBucket,
156 | });
157 | return resolve(true);
158 | } catch (error) {
159 | return reject(error);
160 | }
161 | });
162 | };
163 |
164 | export const getDecryptedBucket = async (
165 | publicKey: string,
166 | encryptionKey: string
167 | ): Promise => {
168 | return new Promise(async (resolve, reject) => {
169 | const jsonCrypto = new JsonCrypto(encryptionKey);
170 | let bucket: IBucket;
171 |
172 | try {
173 | const { data } = await skynetClient.db.getJSON(
174 | publicKey,
175 | SKYTRANSFER_BUCKET
176 | );
177 |
178 | if (data && data.data && typeof data.data === 'string') {
179 | bucket = jsonCrypto.decrypt(data.data) as IBucket;
180 | }
181 |
182 | return resolve(bucket);
183 | } catch (error) {
184 | console.error(error);
185 | return reject(error);
186 | }
187 | });
188 | };
189 |
190 | let mySkyInstance: MySky = null;
191 |
192 | export const getMySky = async (): Promise => {
193 | if (mySkyInstance) {
194 | return mySkyInstance;
195 | }
196 |
197 | return await skynetClient.loadMySky(dataDomain, { debug: true });
198 | };
199 |
200 | export async function getUserReadWriteHiddenBuckets(
201 | mySky: MySky
202 | ): Promise {
203 | let buckets: IReadWriteBucketsInfo = {};
204 |
205 | const { data } = await mySky.getJSONEncrypted(
206 | privateReadWriteUserBucketsPath
207 | );
208 |
209 | if (data && 'buckets' in data) {
210 | buckets = data.buckets as IReadWriteBucketsInfo;
211 | }
212 |
213 | return buckets;
214 | }
215 |
216 | export async function getAllUserHiddenBuckets(
217 | mySky: MySky
218 | ): Promise {
219 | const readOnly: IReadOnlyBucketsInfo = await getUserReadOnlyHiddenBuckets(
220 | mySky
221 | );
222 | const readWrite: IReadWriteBucketsInfo = await getUserReadWriteHiddenBuckets(
223 | mySky
224 | );
225 |
226 | return { readOnly, readWrite };
227 | }
228 |
229 | export async function getAllUserDecryptedBuckets(
230 | mySky: MySky,
231 | buckets: IBucketsInfo
232 | ): Promise {
233 | let readOnly: IBuckets = {};
234 | let readWrite: IBuckets = {};
235 |
236 | await Promise.all(
237 | Object.values(buckets.readOnly).map(async (b) => {
238 | const dBucket = await getDecryptedBucket(b.publicKey, b.encryptionKey);
239 | if (dBucket) {
240 | readOnly[dBucket.uuid] = dBucket;
241 | } else {
242 | console.log('could not decrypt readOnly bucket');
243 | console.log(b);
244 | }
245 | })
246 | );
247 |
248 | await Promise.all(
249 | Object.values(buckets.readWrite).map(async (b) => {
250 | const dBucket = await getDecryptedBucket(
251 | publicKeyFromPrivateKey(b.privateKey),
252 | b.encryptionKey
253 | );
254 | if (dBucket) {
255 | readWrite[dBucket.uuid] = dBucket;
256 | } else {
257 | console.log('could not decrypt readWrite bucket');
258 | console.log(b);
259 | }
260 | })
261 | );
262 |
263 | return { readOnly, readWrite };
264 | }
265 |
266 | export async function getUserReadOnlyHiddenBuckets(
267 | mySky: MySky
268 | ): Promise {
269 | let buckets: IReadOnlyBucketsInfo = {};
270 |
271 | const { data } = await mySky.getJSONEncrypted(privateReadOnlyUserBucketsPath);
272 |
273 | if (data && 'buckets' in data) {
274 | buckets = data.buckets as IReadOnlyBucketsInfo;
275 | }
276 |
277 | return buckets;
278 | }
279 |
280 | export async function storeUserReadWriteHiddenBucket(
281 | mySky: MySky,
282 | newBucket: IReadWriteBucketInfo
283 | ) {
284 | let buckets = await getUserReadWriteHiddenBuckets(mySky);
285 |
286 | buckets[newBucket.bucketID] = newBucket;
287 | try {
288 | await mySky.setJSONEncrypted(privateReadWriteUserBucketsPath, { buckets });
289 | } catch (error) {
290 | throw Error('could not storeUserReadWriteHiddenBucket: ' + error.message);
291 | }
292 | }
293 |
294 | export async function storeUserReadOnlyHiddenBucket(
295 | mySky: MySky,
296 | newBucket: IReadOnlyBucketInfo
297 | ) {
298 | let buckets = await getUserReadOnlyHiddenBuckets(mySky);
299 |
300 | buckets[newBucket.bucketID] = newBucket;
301 | try {
302 | await mySky.setJSONEncrypted(privateReadOnlyUserBucketsPath, { buckets });
303 | } catch (error) {
304 | throw Error('could not storeUserReadOnlyHiddenBucket: ' + error.message);
305 | }
306 | }
307 |
308 | export async function deleteUserReadWriteHiddenBucket(
309 | mySky: MySky,
310 | bucketID: string
311 | ) {
312 | let buckets = await getUserReadWriteHiddenBuckets(mySky);
313 | if (bucketID in buckets) {
314 | delete buckets[bucketID];
315 | try {
316 | await mySky.setJSONEncrypted(privateReadWriteUserBucketsPath, {
317 | buckets,
318 | });
319 | } catch (error) {
320 | throw Error(
321 | 'could not deleteUserReadWriteHiddenBucket: ' + error.message
322 | );
323 | }
324 | }
325 | }
326 |
327 | export async function deleteUserReadOnlyHiddenBucket(
328 | mySky: MySky,
329 | bucketID: string
330 | ) {
331 | let buckets = await getUserReadOnlyHiddenBuckets(mySky);
332 | if (bucketID in buckets) {
333 | delete buckets[bucketID];
334 | try {
335 | await mySky.setJSONEncrypted(privateReadOnlyUserBucketsPath, { buckets });
336 | } catch (error) {
337 | throw Error('could not deleteUserReadOnlyHiddenBucket: ' + error.message);
338 | }
339 | }
340 | }
341 |
342 | export async function pinFile(skylink: string): Promise {
343 | try {
344 | await skynetClient.pinSkylink(skylink);
345 | } catch (error) {
346 | console.error('could not pinFile: ' + error.message);
347 | }
348 | }
349 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | export const fileSize = (size: number): string => {
2 | if (size === 0) return '0 Bytes';
3 | const k = 1024;
4 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
5 | const i = Math.floor(Math.log(size) / Math.log(k));
6 | return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
7 | };
8 |
9 | export const fromStreamToFile = async (
10 | stream: ReadableStream,
11 | filename: string
12 | ): Promise => {
13 | const reader = stream.getReader();
14 | const chunks: BlobPart[] = [];
15 |
16 | const readFile = async () => {
17 | const { done, value } = await reader.read();
18 | if (done) {
19 | return;
20 | }
21 | chunks.push(value);
22 | return readFile();
23 | };
24 |
25 | await readFile();
26 | return new File(chunks, filename, { type: 'text/plain' });
27 | };
28 |
--------------------------------------------------------------------------------
/src/utils/walker.ts:
--------------------------------------------------------------------------------
1 | import { IFileNode } from '../models/file-tree';
2 | import { IEncryptedFiles } from '../models/files/encrypted-file';
3 |
4 | export function renderTree(paths: IEncryptedFiles): IFileNode[] {
5 | let result: IFileNode[] = [];
6 | let level = { result };
7 |
8 | for (let path in paths) {
9 | let p = paths[path];
10 |
11 | p.file.relativePath.split('/').reduce((r, name, i, a) => {
12 | if (!r[name]) {
13 | r[name] = { result: [] };
14 | // uuid of the encrypted file is passed as part of the key in order to have a reference to the file and allow download
15 | r.result.push({
16 | title: name,
17 | key: `${p.uuid}_${name}_${i}`,
18 | children: r[name].result,
19 | isLeaf: false,
20 | });
21 | }
22 |
23 | return r[name];
24 | }, level);
25 | }
26 |
27 | calculateLeaf(result);
28 |
29 | return result;
30 | }
31 |
32 | function calculateLeaf(fileNode: IFileNode[]) {
33 | fileNode.forEach((e) => {
34 | if (e.children.length > 0) {
35 | calculateLeaf(e.children);
36 | } else {
37 | delete e.children;
38 | e.isLeaf = true;
39 | }
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/src/version.ts:
--------------------------------------------------------------------------------
1 | export const APP_VERSION = 'v0.0.0';
2 |
--------------------------------------------------------------------------------
/src/workers/worker.ts:
--------------------------------------------------------------------------------
1 | import { expose } from 'comlink';
2 | import Xchacha20poly1305Encrypt from '../crypto/xchacha20poly1305-encrypt';
3 | import { IEncryptedFile } from '../models/files/encrypted-file';
4 | import Xchacha20poly1305Decrypt from '../crypto/xchacha20poly1305-decrypt';
5 | import { IEncryptionReaderResult } from '../models/encryption';
6 | import { TUS_CHUNK_SIZE } from '../config';
7 |
8 | let fileStream: ReadableStream;
9 | let streamSize: number = 0;
10 |
11 | const initEncryptionReader = async (
12 | file: File,
13 | fileKey: string,
14 | setEncryptProgressCallback
15 | ): Promise => {
16 | const fe = new Xchacha20poly1305Encrypt(file, fileKey);
17 | await fe.encryptAndStream((completed, eProgress) => {
18 | setEncryptProgressCallback(eProgress);
19 | });
20 |
21 | streamSize = fe.getStreamSize();
22 | fileStream = await fe.getStream(TUS_CHUNK_SIZE);
23 | };
24 |
25 | const readChunk = async (): Promise => {
26 | const reader = fileStream.getReader();
27 | const r = await reader.read();
28 | reader.releaseLock();
29 |
30 | return {
31 | value: r.value,
32 | done: r.done,
33 | };
34 | };
35 |
36 | const getStreamSize = (): number => {
37 | return streamSize;
38 | };
39 |
40 | const decryptFile = async (
41 | encryptedFile: IEncryptedFile,
42 | portalUrl: string,
43 | setDecryptProgressCallback,
44 | setDownloadProgressCallback
45 | ): Promise => {
46 | const decrypt = new Xchacha20poly1305Decrypt(encryptedFile, portalUrl);
47 |
48 | let file: File;
49 | try {
50 | file = await decrypt.decrypt(
51 | (completed, eProgress) => {
52 | setDecryptProgressCallback(eProgress);
53 | },
54 | (completed, dProgress) => {
55 | setDownloadProgressCallback(dProgress);
56 | }
57 | );
58 | } catch (error) {
59 | return `error: ${error.toString()}`;
60 | }
61 |
62 | if (!file) {
63 | return 'error: no file';
64 | }
65 |
66 | return Promise.resolve(URL.createObjectURL(file));
67 | };
68 |
69 | const workerApi = {
70 | initEncryptionReader,
71 | readChunk,
72 | decryptFile,
73 | getStreamSize,
74 | };
75 |
76 | export type WorkerApi = typeof workerApi;
77 |
78 | expose(workerApi);
79 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "DOM",
6 | "DOM.Iterable",
7 | "ESNext"
8 | ],
9 | "allowJs": false,
10 | "skipLibCheck": false,
11 | "esModuleInterop": false,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": false,
14 | "module": "ESNext",
15 | "moduleResolution": "Node",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "noEmit": false,
19 | "jsx": "react-jsx",
20 | "noFallthroughCasesInSwitch": true,
21 | "forceConsistentCasingInFileNames": true
22 | },
23 | "include": [
24 | "./src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const CopyPlugin = require('copy-webpack-plugin');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 |
5 | const ASSET_PATH = process.env.ASSET_PATH || '/';
6 |
7 | module.exports = {
8 | entry: {
9 | index: './src/index.tsx',
10 | webworker: './src/workers/worker.ts',
11 | },
12 | output: {
13 | publicPath: ASSET_PATH,
14 | path: path.join(__dirname, 'build'),
15 | filename: (pathData) => {
16 | return pathData.chunk.name === 'index' ? '[name].[contenthash].bundle.js' : '[name].bundle.js';
17 | },
18 | },
19 | mode: process.env.NODE_ENV || 'development',
20 | resolve: {
21 | extensions: ['.tsx', '.ts', '.js'],
22 | fallback: { crypto: require.resolve('crypto-js') },
23 | },
24 | devServer: { static: path.join(__dirname, 'src') },
25 | module: {
26 | rules: [
27 | {
28 | test: /\.m?js/,
29 | resolve: {
30 | fullySpecified: false,
31 | },
32 | },
33 | {
34 | test: /\.(js|mjs|jsx|ts|tsx)$/,
35 | loader: 'source-map-loader',
36 | enforce: 'pre',
37 | },
38 | {
39 | test: /\.(js|jsx)$/,
40 | exclude: /node_modules/,
41 | use: ['babel-loader'],
42 | },
43 | {
44 | test: /\.(ts|tsx)$/,
45 | exclude: /node_modules/,
46 | use: ['ts-loader'],
47 | },
48 | {
49 | test: /\.(css|scss)$/,
50 | use: ['style-loader', 'css-loader'],
51 | },
52 | {
53 | test: /\.less$/,
54 | use: [
55 | {
56 | loader: 'style-loader',
57 | },
58 | {
59 | loader: 'css-loader', // translates CSS into CommonJS
60 | },
61 | {
62 | loader: 'less-loader', // compiles Less to CSS
63 | options: {
64 | lessOptions: {
65 | modifyVars: {
66 | 'primary-color': '#20bf6b',
67 | 'link-color': '#05c46b',
68 | 'border-radius-base': '2px',
69 | },
70 | javascriptEnabled: true,
71 | },
72 | },
73 | },
74 | ],
75 | },
76 | {
77 | test: /\.(jpg|jpeg|png|gif|mp3)$/,
78 | use: ['file-loader'],
79 | },
80 | {
81 | test: /\.svg$/,
82 | use: [
83 | {
84 | loader: 'babel-loader',
85 | },
86 | {
87 | loader: 'react-svg-loader',
88 | options: {
89 | jsx: true, // true outputs JSX tags
90 | },
91 | },
92 | ],
93 | },
94 | ],
95 | },
96 | plugins: [
97 | new HtmlWebpackPlugin({
98 | template: path.join(__dirname, 'public', 'index.html'),
99 | excludeChunks: ['webworker'],
100 | }),
101 | new CopyPlugin({
102 | patterns: [
103 | {
104 | from: path.resolve(__dirname, 'public', 'manifest.json'),
105 | },
106 | {
107 | from: path.join(__dirname, 'public', 'assets'),
108 | to: 'assets',
109 | },
110 | ],
111 | }),
112 | ],
113 | };
114 |
--------------------------------------------------------------------------------