├── .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 | ![skytransfer-logo](https://siasky.net/AAAvflR6XKZukjXy-mDcCfazRKF_CIlu0BG357XXfoC1GQ) 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 | [![Add to Homescreen](https://img.shields.io/badge/Skynet-Add%20To%20Homescreen-00c65e?style=for-the-badge&labelColor=0d0d0d&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAbCAYAAAAdx42aAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAAqACAAQAAAABAAAAIKADAAQAAAABAAAAGwAAAADGhQ7VAAAACXBIWXMAAAsTAAALEwEAmpwYAAACZmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+NTM8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+NjQ8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cnr0gvYAAAe5SURBVEgNlVYJbFzVFT3vL/Nt4yXOyiLahF24EMBrszqhNA1EpZWwK0qxZ2xk0apEpaJFNGkzRCC1VYlUJyoisj22EyrFlqBqaGgqiE0QxPaMSyi1E9JS0pRCwGRx7Njz5289702+lWArSZ8zkz/vv3vvufeee+8T+H9WT7WBVb2uEknVXw9XrENEWw6Bm5Hxr4bnz4IuxmHqHwHBu3D81xGYr6Cq5VMlE9ToEN3e+SbF+T8u+hwKD8SuhQjigKhFrp5Pw0CGOv0gAP9xX0CjWksHDA2wvc+51YqM+DWWtJ7E+U7I0xc1Gr4M4hpE3Ed//YPQtW3IMWZjNB1Q2oFpRJBDYz6Nu/zQJqMASD8nM9zgc5ElMOkeg+83oKLjdXQxErXZSFwaQHj4YOPj9GwLJh0a8tPINXMUviA4oEJtiEMQ+klGJwLH/RI0vZJpWAvLmIMztouIbihgtvcQlnT+PoxEFoD0RUDG78IVhivZ0Mhwt1AR403fCiIm0m4/Q76BHu3j3nRZqSn1vavgG+uZgifID4NR8glEYyRWUm6/jIRAqslE2Xa6xRV6K5/DsA/W3U6yDcILDBp0kR8x+LwVd7Wtl8doWmB7k4HiUz5qSgJ0DwnMKxGoHuKbc4RLNi6F8F8iiPmKH0I7gv9+Xob7/zgmsD82DznBQljeMBbvOKsMK82bqEAESEX3wtC/jnHHRlHEgu1uRVl71ngYIXV+hq8gEOiuNZnvDAaidzAFPSRlIQotjcR9ik78MpuCA9GFCLz76OFRLN35pylVAw21mGPtwvGzDolm0ts3UZZYwfcC8bj8yJRceg3VRFBCEIP1teTGLoIgWcW/6PTtmgrhV9uP4vSsFu7eTI8T6G8oU1p97Q2cSD8Pk9S2DJBcP1H7PXH9so1LAWlcRqu0o4uVsluVqCauQ8ZcwfIihDjL7N6tNpZ2biGIVkTwG7z7SAtOjzqoSPwAgbYEZzMbsff6pAKwKu4q4JInX1xyT/Lii2tkXpaoQmxjFYHNiqXrr7zwYE+cnY7KJaD7jz1PDnwHrtfMnP9C6ZOE9dKLyDwHlTs+nLLxdk0uNFZG1Ytnpvakjk0kJEhM2UPClWrKg595B3nGTeTBngsByEPZSpACAQZja5jubnLDIYN/isqOVqWnr24V336FzD6Mqp2vqbPJhuvgubfxnAthfIAl7YfV2fBLpqDgJqEq7q+xbvaRBzDhvjcdQFZAYKB+tepa8vdAbDfm563DyMQ7BLQB5W2vYs9DhZhtNDHY5eyOvTjhdmINq+jtugpKrCPARcg1jpBw+5Be1K8im9UNHKhrRlHOYzjr/Gc6gLDnpxq6oAUlmPDWYlnnMSSjD1O+g4ICo5k/09OnUdXeh75HFsDyfw5NW8Gg7YPjbEEZz8vyzvPr2Kq/hUAUM4ocTu4eHJ14CVfnbsZs6wmMOZ9OJ1HvSBZUxv2Yxm6Fpb2HwWgU5e07kPZvYTfsxdycb7CmDzAyu9iXC3Fn2w8Zzm8yOtfAMI8gFduPPHEnyjqew+LW5UhnHoXGP1NvxQ0FJ6HjUYxleDzInQ4A1dlAaeIjjPNQxs9HXiSBVP19WN55BK98eA9GJjdJirAx1VLZQRr8HTR/DItbamAHlaqBFUX2EuBxDrANnB+HCeRBfPJJEUn9JIF8QA5wWupD0wGMsIXKZRp/Z8uVdhwOGzkB7lb760ikisRmpmA1vTjEPOexT3wfuv4+gTwN3RhGadtKgvwafT6OK/OfQYH1GYF048r5y8grVlXiDtiZSkxMPDADB0gr2Rteq5uDIobfC66iR3LE/hunxhfjnu7RqflxuKEAY8E2vqtTtS3vABmflxH8CuWJbQpwdoRvxtzcG9jOOaKdvzH2L+L0+AtS13QAUiocSslYG1twjKTLzoG0sxHlHc8qAKUcPlPDRhG0me11lmqzBREg7R1C4MfpcZcCkow9TiI+ieKcBeoCM+mO8vzamQGEkzApS0rrYwpkWjSOUpvEqUYp2d/F/j5c4qpmI4H0P7yIfZ6AjWqmxuFtyOQzb0TuW5Ql8PZe9NTkoyB/E9PXhOLcQpxxvj0zAAk5LMdktAV5ZiNO2TYrwmJyPuPbNahoP6giVcNfg8Xa1EgfjP6MZfesVEHjLgksx7jk0h/geRsZkSH2mBL4uAZVHX+5CIBzXHjzu8W4Iqef6m7ktYogdItvTpOUj5GMO5Uh+RXOBdl2+6JVvKw2M9Tl9JadUWi4ghPNkawWz5GE2aEmB/6UgpkeQi6kordRUIaygDm2YQgrG16vl95uh+30Yp99AnFOvea1Fta/arONrybIXRw4c7MXVsjbtIlii/xwS3BXYljOnIsDkKDCATUQLWded9P4AvaHDA0LemUyGlLhKY7rf9AYicXce/5CVs+1NCzUJwg8Es5gY5NV8FuUJn7ElKhquzSA80G81fhltt0EvV/F/Eqms66YYCEiasbzuqfyLfuG4/OLd0BpOJ9VYXsTVPUUw98sVXJJ20R4uSskpTwvL6mB/2M2oFvP3f1p0KM6Bl36pTHn8gIjAaUdXvOCl8mHZ7Bs5/tZrsSl/7KyFAr5/+UtRbRzwnuY63kLZHe8lyAq6PFCNqM5LFabrfZjah7mXg8MYzdKW/+pDMxwh/wf4xZoOPPcKX0AAAAASUVORK5CYII=)](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 | Buy Me A Coffee 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 | 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 | 208 | 209 |

Continue as anonymous user and create an anonymous bucket.

210 | 223 | 224 | ) : ( 225 | <> 226 | 227 | 228 | Buckets 229 | 230 | 231 | 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 | 261 |
262 | Read write buckets 263 | ( 270 | <> 271 | 272 | 276 | 285 | 298 | 299 | 300 | )} 301 | /> 302 | 303 | Read only buckets 304 | ( 311 | <> 312 | 313 | 317 | 326 | 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 |
15 |
19 |
22 |
23 |
24 |
27 | 58 |
59 |
60 |
63 |
66 |
67 |
68 |
69 | `; 70 | 71 | exports[`TabsCards renders correctly with values 1`] = ` 72 |
73 |
76 |
80 |
83 |
87 |
90 | 100 |
101 |
104 | 114 |
115 |
118 |
119 |
120 |
123 | 154 |
155 |
156 |
159 |
162 |
170 |
171 | Some content 1 172 |
173 |
174 | 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 | 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 |
108 | 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 | 51 | {onDeleteClick && ( 52 | 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 | 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 | 158 | {decryptedBucket && isUserLogged() && ( 159 | 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 | 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 | Buy Me A Coffee 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 | 525 | {decryptedBucket && isUserLogged() && ( 526 | 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 | 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 | --------------------------------------------------------------------------------