├── .env.template ├── .github └── workflows │ ├── cd-to-share.yml │ ├── cd.yml │ └── stale-prs.yml ├── .gitignore ├── .prettierrc.json ├── CODEOWNERS ├── LICENSE ├── README.md ├── infrastructure └── share │ ├── share.Dockerfile │ └── share.env ├── package.json ├── postcss.config.js ├── public ├── Drive-1.webp ├── Photos-2.webp ├── bg.png ├── byemail.png ├── bylink.png ├── favicon.ico ├── images │ └── bg.png ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt └── whitepaper │ └── internxt-white-paper.pdf ├── src ├── App.tsx ├── Layout.tsx ├── assets │ ├── Internxt.svg │ ├── images │ │ ├── HeroSectionImages │ │ │ ├── Blog.svg │ │ │ ├── Blog.webp │ │ │ ├── Pricing.svg │ │ │ ├── Pricing.webp │ │ │ ├── Privacy.svg │ │ │ └── Privacy.webp │ │ ├── footer │ │ │ ├── app-store.svg │ │ │ └── store-for-android.svg │ │ └── share-file │ │ │ ├── banner-bg.png │ │ │ └── visual.svg │ ├── lang │ │ └── en │ │ │ ├── footer.json │ │ │ ├── navbar.json │ │ │ └── send.json │ └── social │ │ ├── black │ │ ├── facebook.svg │ │ ├── instagram.svg │ │ ├── linkedin.svg │ │ └── twitter.svg │ │ ├── cool-gray-30 │ │ ├── facebook.svg │ │ ├── instagram.svg │ │ ├── linkedin.svg │ │ ├── mastodon.svg │ │ ├── twitter.svg │ │ └── youtube.svg │ │ ├── cool-gray-60 │ │ ├── facebook.svg │ │ ├── instagram.svg │ │ ├── linkedin.svg │ │ ├── mastodon.svg │ │ ├── twitter.svg │ │ └── youtube.svg │ │ ├── gdpr-internxt.svg │ │ ├── neutral-300 │ │ ├── facebook.svg │ │ ├── instagram.svg │ │ ├── linkedin.svg │ │ └── twitter.svg │ │ ├── reddit.svg │ │ └── white │ │ ├── facebook.svg │ │ ├── instagram.svg │ │ ├── linkedin.svg │ │ └── twitter.svg ├── components │ ├── Button.tsx │ ├── Card.tsx │ ├── CardBotton.tsx │ ├── Dropdown.tsx │ ├── FancySpinner.tsx │ ├── FaqAccordion.tsx │ ├── FileArea.tsx │ ├── Input.tsx │ ├── ItemList.tsx │ ├── Navbar │ │ └── Navbar.tsx │ ├── NotificationToast.tsx │ ├── RevealX.tsx │ ├── RevealY.tsx │ ├── RootDropzone.tsx │ ├── SendBanner.tsx │ ├── Spinner.tsx │ ├── Switch.tsx │ ├── Tooltip.tsx │ ├── footer │ │ ├── Footer.module.scss │ │ └── Footer.tsx │ ├── send │ │ ├── CtaSection.tsx │ │ ├── FaqSection.tsx │ │ ├── FeatureSection.tsx │ │ └── InfoSection.tsx │ └── utils │ │ └── schema-markup-generator.js ├── constants.ts ├── contexts │ └── Files.tsx ├── index.tsx ├── lib │ ├── auth.ts │ ├── stringUtils.test.ts │ ├── stringUtils.ts │ └── urls.ts ├── logo.svg ├── logo_dark.svg ├── models │ └── SendItem.ts ├── network │ ├── NetworkFacade.ts │ ├── crypto.ts │ ├── download.ts │ ├── requests.ts │ ├── streams.ts │ └── upload.ts ├── react-app-env.d.ts ├── reportWebVitals.ts ├── services │ ├── analytics.service.ts │ ├── download.service.ts │ ├── error-reporting.service.ts │ ├── items.service.ts │ ├── network.service.ts │ ├── notifications.service.ts │ ├── stream.service.ts │ ├── upload.service.test.ts │ ├── upload.service.ts │ └── zip │ │ ├── FlatFolderZip.ts │ │ └── Zip.ts ├── setupTests.ts ├── styles │ ├── fonts │ │ └── InstrumentSans │ │ │ ├── InstrumentSans-Bold.woff2 │ │ │ ├── InstrumentSans-Medium.woff2 │ │ │ ├── InstrumentSans-Regular.woff2 │ │ │ ├── InstrumentSans-SemiBold.woff2 │ │ │ ├── InstrumentSans[wdth,wght].woff2 │ │ │ ├── OFL.txt │ │ │ └── font.css │ └── index.css └── views │ ├── DownloadView.tsx │ ├── HomeView.tsx │ └── NotFoundView.tsx ├── tailwind.config.js ├── tsconfig.json ├── vitest.config.js └── yarn.lock /.env.template: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | PORT=5000 3 | 4 | REACT_APP_SEND_USER= 5 | REACT_APP_SEND_PASS= 6 | REACT_APP_SEND_ENCRYPTION_KEY= 7 | REACT_APP_SEND_BUCKET_ID= 8 | REACT_APP_SEND_API_URL=http://drive-server-wip:3004/api 9 | 10 | REACT_APP_NETWORK_URL=http://network-api:6382 11 | REACT_APP_PROXY="" 12 | REACT_APP_DONT_USE_PROXY=false 13 | REACT_APP_SENTRY_DSN= 14 | REACT_APP_RECAPTCHA_V3= 15 | REACT_APP_GA_ID= 16 | -------------------------------------------------------------------------------- /.github/workflows/cd-to-share.yml: -------------------------------------------------------------------------------- 1 | name: CD to share.internxt.com 2 | on: 3 | push: 4 | branches: [master, feat/share-domain] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - run: echo ${{ secrets.SHARE_NGINX_CONFIG }} | base64 -d > ./infrastructure/share/nginx.conf 11 | 12 | - run: echo REACT_APP_SEGMENT_KEY=${{ secrets.REACT_APP_SEGMENT_KEY }} >> ./infrastructure/share/share.env 13 | - run: echo REACT_APP_CRYPTO_SECRET=${{ secrets.REACT_APP_CRYPTO_SECRET }} >> ./infrastructure/share/share.env 14 | - run: echo REACT_APP_STRIPE_PK=${{ secrets.REACT_APP_STRIPE_PK }} >> ./infrastructure/share/share.env 15 | - run: echo REACT_APP_STRIPE_TEST_PK=${{ secrets.REACT_APP_STRIPE_TEST_PK }} >> ./infrastructure/share/share.env 16 | - run: echo REACT_APP_SEND_API_URL=${{ secrets.REACT_APP_SEND_API_URL }} >> ./infrastructure/share/share.env 17 | - run: echo GENERATE_SOURCEMAP=${{ secrets.GENERATE_SOURCEMAP }} >> ./infrastructure/share/share.env 18 | - run: echo REACT_APP_MAGIC_IV=${{ secrets.REACT_APP_MAGIC_IV }} >> ./infrastructure/share/share.env 19 | - run: echo REACT_APP_MAGIC_SALT=${{ secrets.REACT_APP_MAGIC_SALT }} >> ./infrastructure/share/share.env 20 | - run: echo REACT_APP_CRYPTO_SECRET2=${{ secrets.REACT_APP_CRYPTO_SECRET2 }} >> ./infrastructure/share/share.env 21 | - run: echo REACT_APP_PROXY=${{ secrets.REACT_APP_PROXY }} >> ./infrastructure/share/share.env 22 | - run: echo REACT_APP_NETWORK_URL=${{ secrets.REACT_APP_NETWORK_URL }} >> ./infrastructure/share/share.env 23 | - run: echo REACT_APP_SEND_USER=${{ secrets.REACT_APP_SEND_USER }} >> ./infrastructure/share/share.env 24 | - run: echo REACT_APP_SEND_PASS=${{ secrets.REACT_APP_SEND_PASS }} >> ./infrastructure/share/share.env 25 | - run: echo REACT_APP_SEND_ENCRYPTION_KEY=${{ secrets.REACT_APP_SEND_ENCRYPTION_KEY }} >> ./infrastructure/share/share.env 26 | - run: echo REACT_APP_SEND_BUCKET_ID=${{ secrets.REACT_APP_SEND_BUCKET_ID }} >> ./infrastructure/share/share.env 27 | - run: echo REACT_APP_SENTRY_DSN=${{ secrets.REACT_APP_SENTRY_DSN }} >> ./infrastructure/share/share.env 28 | 29 | - run: echo "registry=https://registry.yarnpkg.com/" > .npmrc 30 | - run: echo "@internxt:registry=https://npm.pkg.github.com" >> .npmrc 31 | # You cannot read packages from other private repos with GITHUB_TOKEN 32 | # You have to use a PAT instead https://github.com/actions/setup-node/issues/49 33 | - run: echo //npm.pkg.github.com/:_authToken=${{ secrets.PERSONAL_ACCESS_TOKEN }} >> .npmrc 34 | - run: echo "always-auth=true" >> .npmrc 35 | 36 | - name: Login to DockerHub 37 | uses: docker/login-action@v1 38 | with: 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | - name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v1 43 | - name: Build and push to send-web 44 | uses: docker/build-push-action@v2 45 | with: 46 | context: ./ 47 | file: ./infrastructure/share/share.Dockerfile 48 | push: true 49 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/send-web:${{ github.sha }} 50 | deploy: 51 | needs: build 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@master 55 | - name: Updates drive-web cluster image 56 | uses: steebchen/kubectl@v2.0.0 57 | with: # defaults to latest kubectl binary version 58 | config: ${{ secrets.KUBE_CONFIG_DRIVE_SERVER }} 59 | command: set image --record deployment/send-web-share-dp send-web-share=${{ secrets.DOCKERHUB_USERNAME }}/send-web:${{ github.sha }} -n drive 60 | - name: Verify deployment 61 | uses: steebchen/kubectl@v2.0.0 62 | with: 63 | config: ${{ secrets.KUBE_CONFIG_DRIVE_SERVER }} 64 | version: v1.20.2 # specify kubectl binary version explicitly 65 | command: rollout status deployment/send-web-share-dp 66 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Send Web CD 2 | on: 3 | push: 4 | branches: [master, feat/cd] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: read 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | registry-url: "https://npm.pkg.github.com" 22 | - run: echo REACT_APP_SEND_USER=${{ secrets.REACT_APP_SEND_USER }} >> ./.env 23 | - run: echo REACT_APP_SEND_PASS=${{ secrets.REACT_APP_SEND_PASS }} >> ./.env 24 | - run: echo REACT_APP_SEGMENT_KEY=${{ secrets.REACT_APP_SEGMENT_KEY }} >> ./.env 25 | - run: echo REACT_APP_GA_ID=${{ secrets.REACT_APP_GA_ID }} >> ./.env 26 | - run: echo REACT_APP_CRYPTO_SECRET=${{ secrets.REACT_APP_CRYPTO_SECRET }} >> ./.env 27 | - run: echo REACT_APP_STRIPE_PK=${{ secrets.REACT_APP_STRIPE_PK }} >> ./.env 28 | - run: echo REACT_APP_STRIPE_TEST_PK=${{ secrets.REACT_APP_STRIPE_TEST_PK }} >> ./.env 29 | - run: echo REACT_APP_SEND_API_URL=${{ secrets.REACT_APP_SEND_API_URL }} >> ./.env 30 | - run: echo REACT_APP_MAGIC_IV=${{ secrets.REACT_APP_MAGIC_IV }} >> ./.env 31 | - run: echo REACT_APP_MAGIC_SALT=${{ secrets.REACT_APP_MAGIC_SALT }} >> ./.env 32 | - run: echo REACT_APP_CRYPTO_SECRET2=${{ secrets.REACT_APP_CRYPTO_SECRET2 }} >> ./.env 33 | - run: echo REACT_APP_PROXY=${{ secrets.REACT_APP_PROXY }} >> ./.env 34 | - run: echo REACT_APP_DONT_USE_PROXY=${{ secrets.REACT_APP_DONT_USE_PROXY }} >> ./.env 35 | - run: echo REACT_APP_NETWORK_URL=${{ secrets.REACT_APP_NETWORK_URL }} >> ./.env 36 | - run: echo REACT_APP_RECAPTCHA_V3=${{ secrets.REACT_APP_RECAPTCHA_V3 }} >> ./.env 37 | - run: echo REACT_APP_SEND_ENCRYPTION_KEY=${{ secrets.REACT_APP_SEND_ENCRYPTION_KEY }} >> ./.env 38 | - run: echo REACT_APP_SEND_BUCKET_ID=${{ secrets.REACT_APP_SEND_BUCKET_ID }} >> ./.env 39 | - run: echo REACT_APP_SENTRY_DSN=${{ secrets.REACT_APP_SENTRY_DSN }} >> ./.env 40 | - run: echo REACT_APP_NODE_ENV=production >> ./.env 41 | - run: echo "registry=https://registry.yarnpkg.com/" > .npmrc 42 | - run: echo "@internxt:registry=https://npm.pkg.github.com" >> .npmrc 43 | # You cannot read packages from other private repos with GITHUB_TOKEN 44 | # You have to use a PAT instead https://github.com/actions/setup-node/issues/49 45 | - run: echo //npm.pkg.github.com/:_authToken=${{ secrets.PERSONAL_ACCESS_TOKEN }} >> .npmrc 46 | - run: echo "always-auth=true" >> .npmrc 47 | 48 | # Setup node 49 | - name: Use Node.js ${{ matrix.node-version }} 50 | uses: actions/setup-node@v4 51 | with: 52 | node-version: ${{ matrix.node-version }} 53 | 54 | # Setup dependencies 55 | - run: yarn 56 | env: 57 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | # Build 60 | - run: yarn run build 61 | env: 62 | CI: false 63 | 64 | # Upload build directory as an artifact 65 | - name: Upload build directory 66 | uses: actions/upload-artifact@v3 67 | with: 68 | name: build 69 | path: build/ 70 | 71 | publish: 72 | needs: build 73 | runs-on: ubuntu-latest 74 | permissions: 75 | contents: read 76 | deployments: write 77 | name: Publish to Cloudflare Pages 78 | steps: 79 | - name: Checkout repo 80 | uses: actions/checkout@v4 81 | 82 | # Download the build artifact 83 | - name: Download build artifact 84 | uses: actions/download-artifact@v3 85 | with: 86 | name: build 87 | path: build 88 | 89 | - name: Publish to Cloudflare Pages 90 | uses: cloudflare/pages-action@v1 91 | with: 92 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 93 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 94 | projectName: send-web 95 | directory: build 96 | # Optional: Enable this if you want to have GitHub Deployments triggered 97 | # gitHubToken: ${{ secrets.GITHUB_TOKEN }} 98 | # Optional: Switch what branch you are publishing to. 99 | # By default this will be the branch which triggered this workflow 100 | branch: main 101 | # Optional: Change the working directory 102 | # workingDirectory: my-site 103 | # Optional: Change the Wrangler version, allows you to point to a specific version or a tag such as `beta` 104 | wranglerVersion: "3" 105 | -------------------------------------------------------------------------------- /.github/workflows/stale-prs.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale PRs 2 | 3 | on: 4 | schedule: 5 | - cron: "0 7 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/stale@v9 18 | with: 19 | repo-token: ${{ secrets.GITHUB_TOKEN }} 20 | days-before-stale: 30 21 | days-before-close: 15 22 | # Custom messages 23 | stale-pr-message: > 24 | This pull request has been inactive for 30 days. Is it still in progress? 25 | If so, please leave a comment or make an update to keep it open. Otherwise, it will be closed automatically in 15 days. 26 | close-pr-message: > 27 | This pull request was closed automatically due to prolonged inactivity. 28 | # Labels 29 | stale-pr-label: stalled 30 | exempt-pr-labels: "dependencies,blocked" 31 | exempt-draft-pr: true 32 | ascending: true 33 | # Do not touch issues 34 | days-before-issue-stale: -1 35 | days-before-issue-close: -1 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 106 | 107 | # dependencies 108 | /node_modules 109 | /.pnp 110 | .pnp.js 111 | 112 | # testing 113 | /coverage 114 | 115 | # production 116 | /build 117 | 118 | # misc 119 | .DS_Store 120 | .env.local 121 | .env.development.local 122 | .env.test.local 123 | .env.production.local 124 | 125 | npm-debug.log* 126 | yarn-debug.log* 127 | yarn-error.log* 128 | 129 | .npmrc 130 | index.generated.css 131 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @xabg2 @larryrider @CandelR 2 | 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Internxt 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 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | -------------------------------------------------------------------------------- /infrastructure/share/share.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.12 2 | 3 | RUN apk update 4 | RUN apk add nginx git 5 | 6 | WORKDIR /app 7 | COPY package.json yarn.lock ./ 8 | COPY .npmrc /app/.npmrc 9 | 10 | RUN yarn 11 | COPY . /app 12 | COPY ./infrastructure/share/share.env /app/.env 13 | RUN yarn build 14 | 15 | RUN mkdir -p /run/nginx 16 | COPY ./infrastructure/share/nginx.conf /etc/nginx/nginx.conf 17 | 18 | EXPOSE 80 19 | 20 | CMD ["nginx","-g","daemon off;"] 21 | -------------------------------------------------------------------------------- /infrastructure/share/share.env: -------------------------------------------------------------------------------- 1 | # ----These can be public ---- 2 | REACT_APP_API_URL=https://send.internxt.com 3 | GENERATE_SOURCEMAP=false 4 | REACT_APP_NETWORK_URL=https://api.internxt.com 5 | PUBLIC_URL=https://share.internxt.com/s 6 | REACT_APP_BASE_URL=/s 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "send-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@headlessui/react": "^1.6.6", 7 | "@internxt/inxt-js": "=1.2.21", 8 | "@internxt/lib": "^1.2.0", 9 | "@internxt/sdk": "^0.15.5", 10 | "@sentry/react": "^7.3.1", 11 | "@sentry/tracing": "^7.3.1", 12 | "@testing-library/jest-dom": "^5.16.4", 13 | "@testing-library/react": "^13.3.0", 14 | "@testing-library/user-event": "^13.5.0", 15 | "@types/jest": "^27.5.2", 16 | "@types/node": "^16.11.41", 17 | "@types/react": "^18.0.21", 18 | "@types/react-dom": "^18.0.5", 19 | "@types/streamsaver": "^2.0.1", 20 | "@types/uuid": "^8.3.4", 21 | "asmcrypto.js": "^2.3.2", 22 | "async": "^3.2.4", 23 | "axios": "^0.27.2", 24 | "bip39": "^3.0.4", 25 | "bytes": "^3.1.2", 26 | "copy-to-clipboard": "^3.3.1", 27 | "hamburger-react": "^2.5.0", 28 | "js-file-download": "^0.4.12", 29 | "lodash.throttle": "^4.1.1", 30 | "moment": "^2.29.4", 31 | "phosphor-react": "^1.4.1", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-dropzone": "14.2.1", 35 | "react-hot-toast": "^2.2.0", 36 | "react-markdown": "^8.0.3", 37 | "react-router-dom": "6", 38 | "react-scripts": "4.0.3", 39 | "streamsaver": "^2.0.6", 40 | "typescript": "^4.7.4", 41 | "uuid": "^9.0.0", 42 | "web-vitals": "^2.1.4" 43 | }, 44 | "scripts": { 45 | "start": "concurrently \"yarn start:css\" \"react-scripts start\"", 46 | "build": "yarn run build:css && react-scripts build", 47 | "test": "vitest run src/", 48 | "eject": "react-scripts eject", 49 | "start:css": "tailwindcss -w -i ./src/styles/index.css -o ./src/styles/index.generated.css", 50 | "build:css": "tailwindcss -m -i ./src/styles/index.css -o ./src/styles/index.generated.css", 51 | "add:npmrc": "echo \"registry=https://registry.yarnpkg.com/\" >> .npmrc && echo \"@internxt:registry=https://npm.pkg.github.com\" >> .npmrc && echo \"//npm.pkg.github.com/:_authToken=$NPM_TOKEN\" >> .npmrc && echo \"always-auth=true\" >> .npmrc", 52 | "vercel:install": "yarn run add:npmrc && yarn install --ignore-engines" 53 | }, 54 | "eslintConfig": { 55 | "extends": [ 56 | "react-app", 57 | "react-app/jest" 58 | ] 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | }, 72 | "devDependencies": { 73 | "@types/async": "^3.2.13", 74 | "@types/bytes": "^3.1.1", 75 | "@types/lodash.throttle": "^4.1.7", 76 | "@types/mime-types": "^2.1.1", 77 | "@vitejs/plugin-react": "^4.3.4", 78 | "autoprefixer": "^10.4.7", 79 | "concurrently": "^7.2.2", 80 | "postcss": "^8.4.14", 81 | "postcss-cli": "^9.1.0", 82 | "prettier": "^2.7.1", 83 | "prettier-plugin-tailwindcss": "^0.1.11", 84 | "tailwindcss": "^3.1.4", 85 | "vitest": "^2.1.8" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/Drive-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/Drive-1.webp -------------------------------------------------------------------------------- /public/Photos-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/Photos-2.webp -------------------------------------------------------------------------------- /public/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/bg.png -------------------------------------------------------------------------------- /public/byemail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/byemail.png -------------------------------------------------------------------------------- /public/bylink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/bylink.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/favicon.ico -------------------------------------------------------------------------------- /public/images/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/images/bg.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | 29 | 30 | Internxt Send – Securely Send Large Files for Free 31 | 78 | 79 | 80 | 90 | 91 | 92 | 93 |
94 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: /download/* -------------------------------------------------------------------------------- /public/whitepaper/internxt-white-paper.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/public/whitepaper/internxt-white-paper.pdf -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "react-hot-toast"; 2 | import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from "react-router-dom"; 3 | import RootDropzone from "./components/RootDropzone"; 4 | import { FilesProvider } from "./contexts/Files"; 5 | import DownloadView from "./views/DownloadView"; 6 | import HomeView from "./views/HomeView"; 7 | import NotFoundView from "./views/NotFoundView"; 8 | 9 | const browserRouterConfig: { basename?: string } = {}; 10 | if (process.env.REACT_APP_BASE_URL) { 11 | browserRouterConfig.basename = process.env.REACT_APP_BASE_URL; 12 | } 13 | 14 | function DownloadRedirectWrapper() { 15 | const { sendId } = useParams(); 16 | const [searchParams] = useSearchParams(); 17 | const code = searchParams.get("code") ?? ''; 18 | 19 | return ; 20 | } 21 | 22 | function App() { 23 | return ( 24 | <> 25 | 26 | 27 | 31 | 32 | 33 | 34 | 35 | } 36 | /> 37 | } /> 38 | } /> 39 | } /> 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | export default App; 48 | -------------------------------------------------------------------------------- /src/assets/Internxt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/images/HeroSectionImages/Blog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/assets/images/HeroSectionImages/Blog.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/assets/images/HeroSectionImages/Blog.webp -------------------------------------------------------------------------------- /src/assets/images/HeroSectionImages/Pricing.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/assets/images/HeroSectionImages/Pricing.webp -------------------------------------------------------------------------------- /src/assets/images/HeroSectionImages/Privacy.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/assets/images/HeroSectionImages/Privacy.webp -------------------------------------------------------------------------------- /src/assets/images/footer/app-store.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/footer/store-for-android.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/share-file/banner-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/assets/images/share-file/banner-bg.png -------------------------------------------------------------------------------- /src/assets/images/share-file/visual.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 12 | 17 | 22 | 24 | 25 | -------------------------------------------------------------------------------- /src/assets/lang/en/footer.json: -------------------------------------------------------------------------------- 1 | { 2 | "DownloadApp": { 3 | "title": "Download Internxt", 4 | "description": "Seamless file management and secure cloud storage on the go with the Internxt mobile app for iOS or Android." 5 | }, 6 | "NewsletterSection": { 7 | "title": "Newsletter", 8 | "description": "Want to hear from us when we launch new products or release a new update? Type your mail address and we’ll do the rest!", 9 | "input": "Your email address", 10 | "info": "You can unsubscribe at any time", 11 | "cta": "Subscribe", 12 | "privacy": "By subscribing you agree to our", 13 | "privacyLink": "privacy policy." 14 | }, 15 | "FooterSection": { 16 | "description": "Internxt’s mission is to help shape a world that respects humanity’s privacy rights. To do so, we are releasing a series of services, which all have the common goal of helping humanity protect this fundamental right.", 17 | "copyright": { 18 | "line1": "Copyright © ", 19 | "line2": ", Internxt Universal Technologies SL" 20 | }, 21 | "comingSoon": "Coming soon", 22 | "new": "New", 23 | "sections": { 24 | "products": { 25 | "title": "Products", 26 | "drive": "Internxt Drive", 27 | "photos": "Internxt Photos", 28 | "send": "Internxt Send", 29 | "webDAV": "WebDAV", 30 | "vpn": "Internxt VPN", 31 | "token": "Token", 32 | "pricing": "Pricing", 33 | "business": "Internxt for Business" 34 | }, 35 | "company": { 36 | "title": "Company", 37 | "about": "About Us", 38 | "privacy": "Privacy", 39 | "openSource": "Open Source", 40 | "mediaArea": "Media Area", 41 | "security": "Security", 42 | "legal": "Legal", 43 | "whyInternxt": "Why Internxt?" 44 | }, 45 | "join": { 46 | "title": "Join Us", 47 | "newsletter": "Newsletter", 48 | "affiliates": "Affiliates", 49 | "storageForEducation": "Internxt for Education", 50 | "signup": "Create Account", 51 | "support": "Support", 52 | "login": "Log In", 53 | "community": "Community", 54 | "github": "GitHub", 55 | "whitePaper": "White Paper", 56 | "twitter": "Twitter", 57 | "facebook": "Facebook", 58 | "linkedin": "LinkedIn", 59 | "youtube": "Youtube", 60 | "instagram": "Instagram", 61 | "mastodon": "Mastodon" 62 | }, 63 | "resources": { 64 | "title": "Resources", 65 | "blog": "Blog", 66 | "comparison": "Cloud Storage Comparison", 67 | "directoryOfPrivacyOrganizations": "Directory of Privacy Organizations", 68 | "pCloudAlternative": "pCloud Alternative", 69 | "cyberAwareness": "Cyber Awareness", 70 | "whatGoogleKnowsAboutMe": "What Google Knows" 71 | }, 72 | "tools": { 73 | "title": "Free Tools", 74 | "temporaryEmail": "Temporary Email", 75 | "fileVirusScan": "File Virus Scanner", 76 | "passwordChecker": "Password Checker", 77 | "byteConverter": "Byte Converter", 78 | "passwordGenerator": "Password Generator", 79 | "fileConverter": "File Converter" 80 | } 81 | } 82 | }, 83 | "Cookies": { 84 | "title": "Internxt uses cookies to make this website easier to use", 85 | "link": "Learn more about cookies", 86 | "close": "Ok, close" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/assets/lang/en/navbar.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "products": "Products", 4 | "about": "About us", 5 | "pricing": "Pricing", 6 | "ourValues": "Our values", 7 | "login": "Log in", 8 | "getStarted": "Get Internxt", 9 | "checkout": "Claim now!" 10 | }, 11 | "products": { 12 | "drive": "Internxt Drive", 13 | "photos": "Internxt Photos", 14 | "send": "Internxt Send", 15 | "s3": "Internxt S3", 16 | "vpn": "Internxt VPN", 17 | "comingSoon": "Coming soon", 18 | "new": "New" 19 | }, 20 | "ourValues": { 21 | "privacy": "Privacy", 22 | "openSource": "Open Source" 23 | }, 24 | "Auth": { 25 | "SignUp": { 26 | "title": "Create an Internxt account", 27 | "or": "or", 28 | "login": "Log in", 29 | "disclaimer": { 30 | "text": "By creating an account you accept the", 31 | "link": "terms and conditions" 32 | }, 33 | "fields": { 34 | "email": { 35 | "label": "Email", 36 | "placeholder": "Email" 37 | }, 38 | "password": { 39 | "label": "Password", 40 | "placeholder": "Password", 41 | "strength": { 42 | "complexity": "Password is not complex enough", 43 | "length": "Password has to be at least 8 characters long", 44 | "weak": "Password is weak", 45 | "strong": "Password is strong" 46 | } 47 | }, 48 | "submit": "Get started" 49 | } 50 | }, 51 | "LogIn": { 52 | "title": "Log in to Internxt", 53 | "or": "or", 54 | "signup": "Create account", 55 | "fields": { 56 | "email": { 57 | "label": "Email", 58 | "placeholder": "Email" 59 | }, 60 | "password": { 61 | "label": "Password", 62 | "placeholder": "Password", 63 | "helper": "Forgot your password?" 64 | }, 65 | "tfa": { 66 | "label": "Two Factor Authentication", 67 | "placeholder": "2FA Code", 68 | "hint": "2FA Code must be 6 digits" 69 | }, 70 | "submit": "Log in" 71 | } 72 | }, 73 | "Recover": { 74 | "title": "Recover account", 75 | "back": "Back to log in", 76 | "disclaimer": "As specified in the app settings, only you have access to your password, therefore we can't restore your account without it. You can sign up again by entering your email below so we can delete your account and all your files.", 77 | "fields": { 78 | "email": { 79 | "label": "Email", 80 | "placeholder": "Email" 81 | }, 82 | "submit": "Send confirmation email" 83 | }, 84 | "success": { 85 | "title": "Check your email", 86 | "subtitle": "We've send an account reset link to" 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/assets/social/black/facebook.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/social/black/instagram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/social/black/linkedin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/social/black/twitter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-30/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-30/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-30/linkedin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-30/mastodon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-30/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-30/youtube.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-60/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-60/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-60/linkedin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-60/mastodon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-60/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/cool-gray-60/youtube.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/neutral-300/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/neutral-300/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/neutral-300/linkedin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/neutral-300/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/reddit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/social/white/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/white/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/white/linkedin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/social/white/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function Button({ 4 | className = "", 5 | children, 6 | disabled, 7 | outline, 8 | onClick, 9 | }: { 10 | className?: string; 11 | children: ReactNode; 12 | disabled?: boolean; 13 | outline?: boolean; 14 | onClick?: () => void; 15 | }) { 16 | const background = outline 17 | ? "bg-transparent" 18 | : "bg-primary active:bg-primary-dark disabled:bg-gray-40"; 19 | 20 | const textColor = outline 21 | ? "text-primary active:text-primary-dark disabled:text-gray-40" 22 | : "text-white"; 23 | 24 | const border = outline 25 | ? "border border-primary active:border-primary-dark disabled:border-gray-40" 26 | : ""; 27 | 28 | return ( 29 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function Card({ 4 | className = "", 5 | children, 6 | }: { 7 | className?: string; 8 | children?: ReactNode; 9 | }) { 10 | return ( 11 |
14 | {children} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/CardBotton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function CardBottom({ children }: { children: ReactNode }) { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { Menu, Transition } from '@headlessui/react'; 2 | import { ReactNode, Ref } from 'react'; 3 | 4 | export default function Dropdown({ 5 | children, 6 | classButton, 7 | menuItems, 8 | classMenuItems, 9 | openDirection, 10 | buttonInputRef 11 | }: { 12 | children: ReactNode; 13 | classButton?: string; 14 | menuItems?: ReactNode[]; 15 | classMenuItems: string; 16 | openDirection: 'left' | 'right'; 17 | buttonInputRef: Ref; 18 | }): JSX.Element { 19 | return ( 20 | 21 |
22 | {children} 23 | 24 | 33 | 34 | {menuItems && ( 35 |
36 | {menuItems?.map((item, index) => ( 37 | {item} 38 | ))} 39 |
40 | )} 41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/FancySpinner.tsx: -------------------------------------------------------------------------------- 1 | export default function FancySpinner({ 2 | className = "", 3 | progress, 4 | }: { 5 | className?: string; 6 | progress: number; 7 | }) { 8 | return ( 9 |
10 |
11 |

12 | {progress <= 99 ? progress : 99} 13 |

14 |

%

15 |
16 | 17 | 25 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/FaqAccordion.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { PlusCircle } from "phosphor-react"; 3 | import ReactMarkdown from "react-markdown"; 4 | 5 | export default function FaqAccordion({ 6 | question, 7 | answer, 8 | isQuestionBigger = false, 9 | }: { 10 | question: string; 11 | answer: string[]; 12 | isQuestionBigger?: boolean; 13 | }) { 14 | const [active, setActive] = useState(false); 15 | 16 | return ( 17 |
18 | 39 | 40 | 45 | {answer.map((text: any) => { 46 | return {text}; 47 | })} 48 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/FileArea.tsx: -------------------------------------------------------------------------------- 1 | import { File, Folder, PlusCircle } from "phosphor-react"; 2 | import { 3 | ChangeEvent, 4 | forwardRef, 5 | MouseEvent, 6 | ReactNode, 7 | useContext, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | import { FilesContext } from "../contexts/Files"; 12 | import { format } from "bytes"; 13 | import { MAX_BYTES_PER_SEND, MAX_ITEMS_PER_LINK } from "../constants"; 14 | import ItemsList from "./ItemList"; 15 | import Dropdown from "./Dropdown"; 16 | 17 | export default function FileArea({ 18 | className = "", 19 | scroll, 20 | }: { 21 | className?: string; 22 | scroll: boolean; 23 | }) { 24 | const fileInputRef = useRef(null); 25 | const folderInputRef = useRef(null); 26 | const dropdownMenuButtonRef = useRef(null); 27 | const [fileInputKey, setFileInputKey] = useState(Date.now()); 28 | const [folderInputKey, setFolderInputKey] = useState(Date.now()); 29 | 30 | const fileContext = useContext(FilesContext); 31 | 32 | const onFileExplorerOpen = (e: MouseEvent) => { 33 | e.preventDefault(); 34 | fileInputRef.current?.click(); 35 | }; 36 | 37 | const onFolderExplorerOpen = (e: MouseEvent) => { 38 | e.preventDefault(); 39 | folderInputRef.current?.click(); 40 | }; 41 | 42 | const onInputChange = (event: ChangeEvent) => { 43 | if (event.target.files) { 44 | fileContext.addFiles(Array.from(event.target.files)); 45 | } 46 | if (dropdownMenuButtonRef.current?.ariaExpanded === "true") { 47 | dropdownMenuButtonRef.current?.click(); 48 | } 49 | setFileInputKey(Date.now()); 50 | setFolderInputKey(Date.now()); 51 | }; 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 54 | const MenuItem = forwardRef( 55 | ( 56 | { 57 | children, 58 | onClick, 59 | }: { children: ReactNode; onClick: (e: MouseEvent) => void }, 60 | ref 61 | ) => { 62 | return ( 63 |
67 | {children} 68 |
69 | ); 70 | } 71 | ); 72 | 73 | const spaceRemaining = MAX_BYTES_PER_SEND - fileContext.totalFilesSize; 74 | 75 | return ( 76 |
77 | 85 | 95 | {fileContext.itemList.length === 0 ? ( 96 | 100 | ) : ( 101 | <> 102 | 109 | 110 | 121 | 122 |

Folders

123 | , 124 | 125 | 126 |

Files

127 |
, 128 | ]} 129 | > 130 | 131 |
132 |

Add more items

133 |
134 |

135 | {fileContext.totalFilesCount} / {MAX_ITEMS_PER_LINK}{" "} 136 | {fileContext.totalFilesCount > 1 ? "files" : "file"} added 137 |

138 |

·

139 |

{format(spaceRemaining)} remaining

140 |
141 |
142 |
143 | 144 | )} 145 |
146 | ); 147 | } 148 | 149 | function Empty({ 150 | onFileExplorerOpen, 151 | onFolderExplorerOpen, 152 | }: { 153 | onFileExplorerOpen: (e: MouseEvent) => void; 154 | onFolderExplorerOpen: (e: MouseEvent) => void; 155 | }) { 156 | return ( 157 |
161 |
{ 164 | e.stopPropagation(); 165 | }} 166 | > 167 | 173 |
174 |

Upload files

175 |
176 |

177 | or select a folder 178 |

179 |
180 |
181 |
182 |
183 | ); 184 | } 185 | 186 | declare module "react" { 187 | interface HTMLAttributes extends AriaAttributes, DOMAttributes { 188 | // extends React's HTMLAttributes 189 | directory?: string; 190 | webkitdirectory?: string; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { WarningOctagon, Warning, CheckCircle } from "phosphor-react"; 2 | 3 | export default function Input({ 4 | className = "", 5 | label, 6 | type = "text", 7 | accent, 8 | disabled, 9 | placeholder, 10 | value, 11 | onChange, 12 | message, 13 | onFocus, 14 | onBlur, 15 | onPaste, 16 | onKeyDown, 17 | refForInput, 18 | onMouseEnter, 19 | onMouseLeave, 20 | }: { 21 | className?: string; 22 | label?: string; 23 | variant?: "text" | "email"; 24 | accent?: "error" | "warning" | "success"; 25 | disabled?: boolean; 26 | placeholder?: string; 27 | value?: string; 28 | onChange?: (v: string) => void; 29 | onKeyDown?: React.KeyboardEventHandler; 30 | onPaste?: React.ClipboardEventHandler; 31 | onFocus?: () => void; 32 | onBlur?: () => void; 33 | message?: string; 34 | type?: string; 35 | refForInput?: React.RefObject; 36 | onMouseEnter?: React.MouseEventHandler; 37 | onMouseLeave?: React.MouseEventHandler; 38 | }): JSX.Element { 39 | let focusColor: string; 40 | 41 | switch (accent) { 42 | case "error": 43 | focusColor = "focus:border-red-std ring-red-std"; 44 | break; 45 | case "warning": 46 | focusColor = "focus:border-orange ring-orange"; 47 | break; 48 | case "success": 49 | focusColor = "focus:border-green ring-green"; 50 | break; 51 | default: 52 | focusColor = "focus:border-primary ring-primary"; 53 | break; 54 | } 55 | 56 | const borderColor = 57 | "border-gray-20 disabled:border-gray-10 hover:border-gray-30"; 58 | 59 | const backgroundColor = "bg-white disabled:bg-white"; 60 | 61 | const placeholderColor = "placeholder-gray-30"; 62 | 63 | const padding = "px-3"; 64 | 65 | const input = ( 66 |
67 | onChange && onChange(e.target.value)} 77 | onFocus={() => { 78 | onFocus && onFocus(); 79 | }} 80 | onBlur={() => { 81 | onBlur && onBlur(); 82 | }} 83 | onPaste={onPaste} 84 | value={value} 85 | onKeyDown={onKeyDown} 86 | /> 87 |
88 | ); 89 | 90 | let messageColor: string; 91 | let MessageIcon: typeof WarningOctagon | undefined; 92 | 93 | switch (accent) { 94 | case "success": 95 | messageColor = "text-green"; 96 | MessageIcon = CheckCircle; 97 | break; 98 | case "warning": 99 | messageColor = "text-orange"; 100 | MessageIcon = Warning; 101 | break; 102 | case "error": 103 | messageColor = "text-red-std"; 104 | MessageIcon = WarningOctagon; 105 | break; 106 | default: 107 | messageColor = "text-gray-80"; 108 | } 109 | 110 | return ( 111 |
112 | {label ? ( 113 | 120 | ) : ( 121 | input 122 | )} 123 | {message && ( 124 |
125 | {MessageIcon && } 126 |

{message}

127 |
128 | )} 129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/ItemList.tsx: -------------------------------------------------------------------------------- 1 | import bytes from "bytes"; 2 | import { X } from "phosphor-react"; 3 | import { SendItemData } from "../models/SendItem"; 4 | 5 | export default function ItemList({ 6 | className = "", 7 | items, 8 | onRemoveItem, 9 | }: { 10 | className?: string; 11 | items: SendItemData[]; 12 | onRemoveItem?: (item: SendItemData) => void; 13 | }) { 14 | return ( 15 |
16 | {items.map((item) => ( 17 | 18 | ))} 19 |
20 | ); 21 | } 22 | 23 | function Item({ 24 | item, 25 | onRemove, 26 | }: { 27 | item: SendItemData; 28 | onRemove?: (item: SendItemData) => void; 29 | }) { 30 | const extension = 31 | item.type === "file" && 32 | item.name.includes(".") && 33 | item.name.split(".").pop(); 34 | 35 | return ( 36 |
37 |

{item.name}

38 |
39 | {item.type === "folder" && ( 40 | <> 41 |

Folder

42 |

·

43 | {item.countFiles && ( 44 | <> 45 |

46 | {item.countFiles} {item.countFiles === 1 ? "file" : "files"} 47 |

48 |

·

49 | 50 | )} 51 | 52 | )} 53 |

{bytes.format(item.size)}

54 | {extension && ( 55 | <> 56 |

·

57 |

{extension}

58 | 59 | )} 60 |
61 | {onRemove && ( 62 | 68 | )} 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/NotificationToast.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@headlessui/react"; 2 | import { CheckCircle, Info, Warning, WarningOctagon, X } from "phosphor-react"; 3 | import { ToastShowProps, ToastType } from "../services/notifications.service"; 4 | 5 | const NotificationToast = ({ 6 | text, 7 | type, 8 | action, 9 | visible, 10 | closable, 11 | onClose, 12 | }: Omit & { 13 | visible: boolean; 14 | onClose: () => void; 15 | }): JSX.Element => { 16 | let Icon: typeof CheckCircle | undefined; 17 | let IconColor: string | undefined; 18 | 19 | switch (type) { 20 | case ToastType.Success: 21 | Icon = CheckCircle; 22 | IconColor = "text-green"; 23 | break; 24 | case ToastType.Error: 25 | Icon = WarningOctagon; 26 | IconColor = "text-red-50"; 27 | break; 28 | case ToastType.Info: 29 | Icon = Info; 30 | IconColor = "text-primary"; 31 | break; 32 | case ToastType.Warning: 33 | Icon = Warning; 34 | IconColor = "text-yellow"; 35 | break; 36 | } 37 | 38 | return ( 39 | 49 |
53 | {Icon && ( 54 | 55 | )} 56 | 57 |

{text}

58 | {action && ( 59 | 65 | )} 66 | {closable && ( 67 | 70 | )} 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default NotificationToast; 77 | -------------------------------------------------------------------------------- /src/components/RevealX.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | interface RevealProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | direction?: "left" | "right"; 7 | } 8 | 9 | const RevealX = ({ children, className, direction }: RevealProps) => { 10 | useEffect(() => { 11 | function reveal() { 12 | const reveals = document.querySelectorAll( 13 | direction === "left" ? ".revealXLeft" : ".revealXRight" 14 | ); 15 | 16 | for (let i = 0; i < reveals.length; i++) { 17 | const windowHeight = window.innerHeight; 18 | const elementTop = reveals[i].getBoundingClientRect().top; 19 | const elementVisible = 150; 20 | 21 | if (elementTop < windowHeight - elementVisible) { 22 | reveals[i].classList.add("active"); 23 | } 24 | } 25 | } 26 | 27 | window.addEventListener("scroll", reveal); 28 | 29 | return () => { 30 | window.removeEventListener("scroll", reveal); 31 | }; 32 | }, []); 33 | return ( 34 |
39 | {children} 40 |
41 | ); 42 | }; 43 | 44 | export default RevealX; 45 | -------------------------------------------------------------------------------- /src/components/RevealY.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | interface RevealProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | } 7 | 8 | const RevealY = ({ children, className }: RevealProps) => { 9 | useEffect(() => { 10 | function reveal() { 11 | const reveals = document.querySelectorAll('.revealY'); 12 | 13 | for (let i = 0; i < reveals.length; i++) { 14 | const windowHeight = window.innerHeight; 15 | const elementTop = reveals[i].getBoundingClientRect().top; 16 | const elementVisible = 150; 17 | 18 | if (elementTop < windowHeight - elementVisible) { 19 | reveals[i].classList.add('active'); 20 | } 21 | // else { 22 | // reveals[i].classList.remove('active'); 23 | // } 24 | } 25 | } 26 | 27 | window.addEventListener('scroll', reveal); 28 | 29 | return () => { 30 | window.removeEventListener('scroll', reveal); 31 | }; 32 | }, []); 33 | return
{children}
; 34 | }; 35 | 36 | export default RevealY; 37 | -------------------------------------------------------------------------------- /src/components/RootDropzone.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "bytes"; 2 | import { ReactNode, useCallback, useContext } from "react"; 3 | import { useDropzone } from "react-dropzone"; 4 | import { MAX_BYTES_PER_SEND } from "../constants"; 5 | import { FilesContext } from "../contexts/Files"; 6 | 7 | export default function RootDropzone({ 8 | className = "", 9 | children, 10 | }: { 11 | className?: string; 12 | children: ReactNode; 13 | }) { 14 | const filesContext = useContext(FilesContext); 15 | 16 | const onDrop = useCallback( 17 | (acceptedFiles: File[]) => { 18 | filesContext.addFiles(acceptedFiles); 19 | }, 20 | [filesContext] 21 | ); 22 | 23 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ 24 | onDrop, 25 | noClick: true, 26 | disabled: !filesContext.enabled, 27 | noKeyboard: true, 28 | }); 29 | 30 | const maxBytesPerSendDisplay = format(MAX_BYTES_PER_SEND); 31 | 32 | return ( 33 |
34 | 35 | {isDragActive && ( 36 |
37 |
38 |
39 |

40 | Drag and drop your files here 41 |

42 |

43 | Maximum {maxBytesPerSendDisplay} upload limit 44 |

45 |
46 |
47 |
48 | )} 49 | {children} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/SendBanner.tsx: -------------------------------------------------------------------------------- 1 | import { X } from "phosphor-react"; 2 | import React from "react"; 3 | import { ReactComponent as SendIcon } from "../assets/images/share-file/visual.svg"; 4 | import BackgroundImage from "../assets/images/share-file/banner-bg.png"; 5 | 6 | export interface Props { 7 | sendBannerVisible: boolean; 8 | setIsSendBannerVisible: (value: boolean) => void; 9 | } 10 | 11 | const SendBanner = (props: Props) => { 12 | const onClose = () => { 13 | props.setIsSendBannerVisible(false); 14 | }; 15 | 16 | return ( 17 |
22 |
32 | 38 |
39 |
40 |
41 |

42 | Try out Internxt 43 |

44 |

45 | Encrypt, save and share large files with Internxt. 46 |

47 |
48 |
49 | 57 |
58 |
59 |
60 |
61 | 62 |
63 |
64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export default SendBanner; 71 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | const Spinner = ({ className = "" }: { className?: string }): JSX.Element => { 2 | return ( 3 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default Spinner; 18 | -------------------------------------------------------------------------------- /src/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | export default function Switch({ 2 | className = "", 3 | options, 4 | onClick, 5 | value, 6 | }: { 7 | className?: string; 8 | options: readonly [F, S]; 9 | onClick: (value: F | S) => void; 10 | value: F | S; 11 | }) { 12 | const first = options[0]; 13 | const second = options[1]; 14 | 15 | return ( 16 |
19 |
24 |
25 |
26 | onClick(first)} 30 | /> 31 | onClick(second)} 35 | /> 36 |
37 | ); 38 | } 39 | 40 | function SwitchPart({ 41 | text, 42 | active, 43 | onClick, 44 | }: { 45 | text: string; 46 | active: boolean; 47 | onClick?: () => void; 48 | }): JSX.Element { 49 | return ( 50 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useRef, useState } from "react"; 2 | 3 | export default function Tooltip({ 4 | children, 5 | title, 6 | subtitle, 7 | popsFrom, 8 | style = "dark", 9 | className, 10 | delayInMs, 11 | show, 12 | position, 13 | }: { 14 | children?: ReactNode; 15 | title: string; 16 | subtitle?: string; 17 | popsFrom: "right" | "left" | "top" | "bottom"; 18 | style?: "light" | "dark"; 19 | className?: string; 20 | delayInMs?: number; 21 | show?: boolean; 22 | position?: number; 23 | }) { 24 | let tooltipPosition = ""; 25 | let trianglePosition = ""; 26 | let triangle = ""; 27 | 28 | switch (popsFrom) { 29 | case "right": 30 | tooltipPosition = "left-full top-1/2 -translate-y-1/2 ml-1.5"; 31 | trianglePosition = "flex-row-reverse"; 32 | triangle = "polygon(100% 0%, 100% 100%, 0% 50%)"; 33 | break; 34 | case "left": 35 | tooltipPosition = "right-full top-1/2 -translate-y-1/2 mr-1.5"; 36 | trianglePosition = "flex-row"; 37 | triangle = "polygon(0% 0%, 0% 100%, 100% 50%)"; 38 | break; 39 | case "top": 40 | tooltipPosition = 41 | "bottom-full left-1/2 -translate-x-1/2 mb-1.5 origin-bottom"; 42 | trianglePosition = "flex-col"; 43 | triangle = "polygon(0% 0%, 100% 0%, 50% 100%)"; 44 | break; 45 | case "bottom": 46 | tooltipPosition = "top-full left-1/2 -translate-x-1/2 mt-1.5"; 47 | trianglePosition = "flex-col-reverse"; 48 | triangle = "polygon(50% 0%, 0% 100%, 100% 100%)"; 49 | break; 50 | } 51 | 52 | return ( 53 |
59 |
64 |
69 |

74 | {title} 75 | {children} 76 |

77 | {subtitle && ( 78 |

83 | {subtitle} 84 |

85 | )} 86 |
87 |
98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/components/footer/Footer.module.scss: -------------------------------------------------------------------------------- 1 | @supports not (backdrop-filter: none) { 2 | .cookiesBgFallback { 3 | --tw-bg-opacity: 1; 4 | } 5 | } -------------------------------------------------------------------------------- /src/components/send/CtaSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import textContent from "../../assets/lang/en/send.json"; 3 | 4 | const CtaSection = () => { 5 | return ( 6 |
7 |
8 |
9 |

10 | {textContent.CtaSection.title} 11 |

12 |

13 | {textContent.CtaSection.description} 14 |

15 |
16 |
{ 18 | window.open("https://internxt.com/pricing", "_blank"); 19 | }} 20 | className="flex max-w-[260px] cursor-pointer flex-col items-center rounded-lg bg-white text-center hover:bg-blue-10" 21 | > 22 |

23 | {textContent.CtaSection.cta} 24 |

25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default CtaSection; 32 | -------------------------------------------------------------------------------- /src/components/send/FaqSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import FaqAccordion from "../FaqAccordion"; 3 | 4 | import label from "../../assets/lang/en/send.json"; 5 | 6 | const FaqSection = () => { 7 | return ( 8 |
9 |
10 |

11 | {label.FaqSection.title} 12 |

13 |
14 | {label.FaqSection.faq.map((item) => ( 15 |
19 | 25 |
26 | ))} 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default FaqSection; 34 | -------------------------------------------------------------------------------- /src/components/send/FeatureSection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | User, 3 | PaperPlaneTilt, 4 | FileDotted, 5 | Eye, 6 | EyeSlash, 7 | EnvelopeSimple, 8 | } from "phosphor-react"; 9 | import label from "../../assets/lang/en/send.json"; 10 | import RevealY from "../RevealY"; 11 | 12 | const FeatureSection = () => { 13 | const cards = [ 14 | { 15 | id: 1, 16 | icon: User, 17 | title: label.FeatureSection.card1.title, 18 | description: label.FeatureSection.card1.description, 19 | }, 20 | { 21 | id: 2, 22 | icon: EyeSlash, 23 | title: label.FeatureSection.card2.title, 24 | description: label.FeatureSection.card2.description, 25 | }, 26 | { 27 | id: 3, 28 | icon: PaperPlaneTilt, 29 | title: label.FeatureSection.card3.title, 30 | description: label.FeatureSection.card3.description, 31 | }, 32 | { 33 | id: 4, 34 | icon: FileDotted, 35 | title: label.FeatureSection.card4.title, 36 | description: label.FeatureSection.card4.description, 37 | }, 38 | { 39 | id: 5, 40 | icon: EnvelopeSimple, 41 | title: label.FeatureSection.card5.title, 42 | description: label.FeatureSection.card5.description, 43 | }, 44 | { 45 | id: 6, 46 | icon: Eye, 47 | title: label.FeatureSection.card6.title, 48 | description: label.FeatureSection.card6.description, 49 | }, 50 | ]; 51 | 52 | return ( 53 |
54 |
55 |

56 | {label.FeatureSection.title} 57 |

58 |

59 | {label.FeatureSection.description} 60 |

61 |
62 | 63 | {cards.map((card) => ( 64 |
68 | 69 |
70 |

{card.title}

71 |

{card.description}

72 |
73 |
74 | ))} 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default FeatureSection; 81 | -------------------------------------------------------------------------------- /src/components/send/InfoSection.tsx: -------------------------------------------------------------------------------- 1 | import label from "../../assets/lang/en/send.json"; 2 | import RevealX from "../RevealX"; 3 | 4 | const urlPrefix = process.env.REACT_APP_BASE_URL || ""; 5 | 6 | const InfoSection = () => { 7 | return ( 8 |
9 |
10 |

{label.InfoSection.title}

11 |

{label.InfoSection.description}

12 |
13 |
14 | 18 |
19 |

20 | {label.InfoSection.card1.title} 21 |

22 |
23 |

24 | {label.InfoSection.card1.description.line1} 25 |

26 |

27 | {label.InfoSection.card1.description.line2} 28 |

29 |
30 |
31 |
32 | 33 |
34 |
35 | Internxt Photos 36 |
37 |
38 |
39 | 40 | 44 |
45 |

46 | {label.InfoSection.card2.title} 47 |

48 |
49 |

50 | {label.InfoSection.card2.description.line1} 51 |

52 |

53 | {label.InfoSection.card2.description.line2} 54 |

55 |
56 |
57 |
58 | 59 |
60 |
61 | Internxt Photos 62 |
63 |
64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export default InfoSection; 71 | -------------------------------------------------------------------------------- /src/components/utils/schema-markup-generator.js: -------------------------------------------------------------------------------- 1 | // Schema markup generation funcitons 2 | 3 | // FAQ Data Structure generator 4 | // Additional information: https://developers.google.com/search/docs/appearance/structured-data/faqpage 5 | 6 | export const sm_faq = (faq) => { 7 | let data = `{ 8 | "@context": "https://schema.org", 9 | "@type": "FAQPage", 10 | "mainEntity": [`; 11 | 12 | faq.forEach((item, i, arr) => { 13 | data += `{ 14 | "@type": "Question", 15 | "name": "${item.question}", 16 | "acceptedAnswer": { 17 | "@type": "Answer", 18 | "text": "`; 19 | 20 | if (item.answer.length > 1) { 21 | item.answer.forEach((answer, i, arr) => { 22 | data += `

${answer}

`; 23 | }); 24 | } else { 25 | data += `${item.answer[0]}`; 26 | } 27 | 28 | data += `" 29 | }}`; 30 | if (i + 1 < arr.length) data += ","; 31 | }); 32 | data += `]}`; 33 | return data; 34 | }; 35 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_BYTES_PER_SEND = 5_368_709_120; 2 | export const MAX_ITEMS_PER_LINK = 100; 3 | 4 | export const MAX_RECIPIENTS = 50; 5 | -------------------------------------------------------------------------------- /src/contexts/Files.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "bytes"; 2 | import { createContext, ReactNode, useEffect, useState } from "react"; 3 | import { FileWithPath } from "react-dropzone"; 4 | import { MAX_BYTES_PER_SEND, MAX_ITEMS_PER_LINK } from "../constants"; 5 | import { SendItemData } from "../models/SendItem"; 6 | import { transformInputFilesToJSON, transformJsonFilesToItems } from "../services/items.service"; 7 | import notificationsService, { 8 | ToastType, 9 | } from "../services/notifications.service"; 10 | 11 | type FilesContextT = { 12 | enabled: boolean; 13 | setEnabled: (value: boolean) => void; 14 | itemList: SendItemData[]; 15 | totalFilesCount: number; 16 | totalFilesSize: number; 17 | addFiles: (file: FileWithPath[]) => void; 18 | removeItem: (file: SendItemData) => void; 19 | clear: () => void; 20 | }; 21 | 22 | export const FilesContext = createContext({} as FilesContextT); 23 | 24 | export const FilesProvider = ({ children }: { children: ReactNode }) => { 25 | const [itemList, setItemList] = useState([]); 26 | const [totalFilesCount, setTotalFilesCount] = useState(0); 27 | const [totalFilesSize, setTotalFilesSize] = useState(0); 28 | const [enabled, setEnabled] = useState(true); 29 | 30 | useEffect(() => { 31 | setTotalFilesCount(itemList.reduce( 32 | (prev, current) => prev + (current.type === 'folder' ? current.countFiles || 0 : 1), 33 | 0 34 | )); 35 | setTotalFilesSize(itemList.reduce( 36 | (prev, current) => prev + current.size, 37 | 0 38 | )); 39 | }, [itemList]); 40 | 41 | const addFiles = (newFileList: FileWithPath[]) => { 42 | const thereAreEmptyFiles = newFileList.some((file) => file.size === 0); 43 | if (thereAreEmptyFiles) { 44 | notificationsService.show({ 45 | text: "Empty files are not supported", 46 | type: ToastType.Warning, 47 | }); 48 | return; 49 | } 50 | 51 | const newFilesTotalSize = newFileList.reduce( 52 | (prev, current) => prev + current.size, 53 | 0 54 | ); 55 | 56 | if (totalFilesCount + newFileList.length > MAX_ITEMS_PER_LINK) { 57 | return notificationsService.show({ 58 | text: `The maximum number of files allowed in total is ${MAX_ITEMS_PER_LINK}`, 59 | type: ToastType.Warning, 60 | }); 61 | } 62 | 63 | if (totalFilesSize + newFilesTotalSize <= MAX_BYTES_PER_SEND) { 64 | const filesJson = transformInputFilesToJSON(newFileList); 65 | let { rootFolders, rootFiles } = transformJsonFilesToItems(filesJson); 66 | 67 | rootFolders = rootFolders.filter((folder) => { 68 | const sameNameItems = itemList.filter(item => item.name === folder.name); 69 | return sameNameItems.length === 0; 70 | }); 71 | 72 | rootFiles = rootFiles.filter((file) => { 73 | const sameNameItems = itemList.filter(item => item.name === file.name); 74 | return sameNameItems.length === 0; 75 | }); 76 | 77 | const sendItemsList = [...rootFolders, ...rootFiles]; 78 | setItemList([...itemList, ...sendItemsList]); 79 | } else { 80 | notificationsService.show({ 81 | text: `The maximum size allowed is ${format( 82 | MAX_BYTES_PER_SEND 83 | )} in total`, 84 | type: ToastType.Warning, 85 | }); 86 | } 87 | } 88 | 89 | const removeItem = (removeItem: SendItemData) => { 90 | setItemList(itemList.filter(item => item.id !== removeItem.id)); 91 | }; 92 | 93 | const clear = () => { 94 | setItemList([]); 95 | } 96 | 97 | return ( 98 | 110 | {children} 111 | 112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import * as Sentry from "@sentry/react"; 4 | import { BrowserTracing } from "@sentry/tracing"; 5 | import "./styles/index.generated.css"; 6 | import App from "./App"; 7 | import reportWebVitals from "./reportWebVitals"; 8 | import notificationsService, { 9 | ToastType, 10 | } from "./services/notifications.service"; 11 | import throttle from "lodash.throttle"; 12 | 13 | Sentry.init({ 14 | dsn: process.env.REACT_APP_SENTRY_DSN, 15 | integrations: [new BrowserTracing()], 16 | tracesSampleRate: 1.0, 17 | debug: process.env.NODE_ENV !== "production", 18 | environment: process.env.NODE_ENV, 19 | }); 20 | 21 | function onUnhandledException(e: ErrorEvent | PromiseRejectionEvent) { 22 | console.error(e); 23 | 24 | notificationsService.show({ 25 | type: ToastType.Error, 26 | text: "Something went wrong, our team has been notified", 27 | }); 28 | } 29 | 30 | const throttledOnUnhandledException = throttle(onUnhandledException, 2000); 31 | 32 | window.addEventListener("error", throttledOnUnhandledException); 33 | window.addEventListener("unhandledrejection", throttledOnUnhandledException); 34 | 35 | const root = ReactDOM.createRoot( 36 | document.getElementById("root") as HTMLElement 37 | ); 38 | root.render( 39 | 40 | 41 | 42 | ); 43 | 44 | // If you want to start measuring performance in your app, pass a function 45 | // to log results (for example: reportWebVitals(console.log)) 46 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 47 | reportWebVitals(); 48 | -------------------------------------------------------------------------------- /src/lib/stringUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { base64UrlSafetoUUID, fromBase64UrlSafe, generateRandomStringUrlSafe, toBase64UrlSafe } from './stringUtils'; 3 | 4 | describe('stringUtils', () => { 5 | describe('toBase64UrlSafe', () => { 6 | it('converts standard Base64 to URL-safe Base64', () => { 7 | const base64 = 'KHh+VVwlYjs/J3E='; 8 | const urlSafe = toBase64UrlSafe(base64); 9 | expect(urlSafe).toBe('KHh-VVwlYjs_J3E'); 10 | }); 11 | 12 | it('removes trailing "=" characters', () => { 13 | const base64 = 'KHh+VVwlYjs/J3FiYw=='; 14 | const urlSafe = toBase64UrlSafe(base64); 15 | expect(urlSafe).toBe('KHh-VVwlYjs_J3FiYw'); 16 | }); 17 | 18 | it('does not modify already URL-safe Base64 strings', () => { 19 | const base64 = 'KHh-VVwlYjs_J3E'; 20 | const urlSafe = toBase64UrlSafe(base64); 21 | expect(urlSafe).toBe(base64); 22 | }); 23 | }); 24 | 25 | describe('fromBase64UrlSafe', () => { 26 | it('converts URL-safe Base64 back to standard Base64', () => { 27 | const urlSafe = 'KHh-VVwlYjs_J3E'; 28 | const base64 = fromBase64UrlSafe(urlSafe); 29 | expect(base64).toBe('KHh+VVwlYjs/J3E='); 30 | }); 31 | 32 | it('does not modify already standard Base64 strings', () => { 33 | const urlSafe = 'KHh+VVwlYjs/J3FiYw=='; 34 | const base64 = fromBase64UrlSafe(urlSafe); 35 | expect(base64).toBe(urlSafe); 36 | }); 37 | }); 38 | 39 | describe('generateRandomStringUrlSafe', () => { 40 | const getRandomNumber = (min: number, max: number) => { 41 | min = Math.ceil(min); 42 | max = Math.floor(max); 43 | return Math.floor(Math.random() * (max - min + 1)) + min; 44 | }; 45 | 46 | it('generates a string of the specified length', () => { 47 | const length = getRandomNumber(1, 1000); 48 | const randomString = generateRandomStringUrlSafe(length); 49 | expect(randomString).toHaveLength(length); 50 | }); 51 | 52 | it('generates a URL-safe string', () => { 53 | const randomString = generateRandomStringUrlSafe(1000); 54 | expect(randomString).not.toMatch(/[+/=]/); 55 | }); 56 | 57 | it('throws an error if size is not positive', () => { 58 | expect(() => generateRandomStringUrlSafe(0)).toThrow('Size must be a positive integer'); 59 | expect(() => generateRandomStringUrlSafe(-5)).toThrow('Size must be a positive integer'); 60 | }); 61 | }); 62 | 63 | describe('base64UrlSafetoUUID', () => { 64 | it('converts a Base64 URL-safe string to a UUID v4 format', () => { 65 | const base64UrlSafe = '8yqR2seZThOqF4xNngMjyQ'; 66 | const uuid = base64UrlSafetoUUID(base64UrlSafe); 67 | expect(uuid).toBe('f32a91da-c799-4e13-aa17-8c4d9e0323c9'); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/lib/stringUtils.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | import { Buffer } from 'buffer'; 3 | import { validate as validateUuidv4 } from 'uuid'; 4 | 5 | /** 6 | * Converts a standard Base64 string into a URL-safe Base64 variant: 7 | * - Replaces all "+" characters with "-" 8 | * - Replaces all "/" characters with "_" 9 | * - Strips any trailing "=" padding characters 10 | * 11 | * @param base64 - A standard Base64–encoded string 12 | * @returns The URL-safe Base64 string 13 | */ 14 | export const toBase64UrlSafe = (base64: string): string => { 15 | return base64 16 | .replace(/\+/g, '-') // converts "+" to "-" 17 | .replace(/\//g, '_') // converts "/" to "_" 18 | .replace(/=+$/, ''); // removes trailing "=" 19 | }; 20 | 21 | /** 22 | * Converts a URL-safe Base64 string back into a standard Base64 variant: 23 | * - Replaces all "-" characters with "+" 24 | * - Replaces all "_" characters with "/" 25 | * - Adds "=" padding characters at the end until length is a multiple of 4 26 | * 27 | * @param urlSafe - A URL-safe Base64–encoded string 28 | * @returns The standard Base64 string, including any necessary "=" padding 29 | */ 30 | export const fromBase64UrlSafe = (urlSafe: string): string => { 31 | let base64 = urlSafe 32 | .replace(/-/g, '+') // convert "-" back to "+" 33 | .replace(/_/g, '/'); // convert "_" back to "/" 34 | 35 | const missingPadding = (4 - (base64.length % 4)) % 4; 36 | if (missingPadding > 0) { 37 | base64 += '='.repeat(missingPadding); 38 | } 39 | return base64; 40 | }; 41 | 42 | /** 43 | * Generates a cryptographically secure, URL-safe string of a given length. 44 | * 45 | * Internally: 46 | * 1. Calculates how many raw bytes are needed to generate at least `size` Base64 chars. 47 | * 2. Generates secure random bytes with `crypto.randomBytes()`. 48 | * 3. Encodes to standard Base64, then makes it URL-safe. 49 | * 4. Truncates the result to `size` characters. 50 | * 51 | * @param size - Desired length of the output string (must be ≥1) 52 | * @returns A URL-safe string exactly `size` characters long 53 | */ 54 | export const generateRandomStringUrlSafe = (size: number): string => { 55 | if (size <= 0) { 56 | throw new Error('Size must be a positive integer'); 57 | } 58 | // Base64 yields 4 chars per 3 bytes, so it computes the minimum bytes required 59 | const numBytes = Math.ceil((size * 3) / 4); 60 | const buf = randomBytes(numBytes).toString('base64'); 61 | 62 | return toBase64UrlSafe(buf).substring(0, size); 63 | }; 64 | 65 | /** 66 | * Converts a base64 url safe string to uuid v4 67 | * 68 | * @example in: `8yqR2seZThOqF4xNngMjyQ` out: `f32a91da-c799-4e13-aa17-8c4d9e0323c9` 69 | */ 70 | export function base64UrlSafetoUUID(base64UrlSafe: string): string { 71 | const hex = Buffer.from(fromBase64UrlSafe(base64UrlSafe), 'base64').toString('hex'); 72 | return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`; 73 | } 74 | 75 | /** 76 | * Extracts a send ID from the provided parameter. If the parameter is not a valid UUIDv4, 77 | * it converts the parameter from a Base64 URL-safe string to a UUID. 78 | * 79 | * @param param - The input parameter, which can be either a UUIDv4 or a Base64 URL-safe string. 80 | * @returns The send ID as a UUID string. 81 | */ 82 | export const decodeSendId = (param: string) => { 83 | if (!validateUuidv4(param)) { 84 | return base64UrlSafetoUUID(param); 85 | } 86 | return param; 87 | }; 88 | 89 | /** 90 | * Encodes a send ID by removing hyphens, converting it from hexadecimal to Base64, 91 | * and then making it URL-safe. 92 | * 93 | * @param sendId - The send ID to be encoded, expected to be a UUID string. 94 | * @returns The encoded send ID as a URL-safe Base64 string. 95 | */ 96 | export const encodeSendId = (sendId: string) => { 97 | if (!validateUuidv4(sendId)) { 98 | throw new Error('Send id is not valid'); 99 | } 100 | const removedUuidDecoration = sendId.replace(/-/g, ''); 101 | const base64endoded = Buffer.from(removedUuidDecoration, 'hex').toString('base64'); 102 | const encodedSendId = toBase64UrlSafe(base64endoded); 103 | return encodedSendId; 104 | }; 105 | -------------------------------------------------------------------------------- /src/lib/urls.ts: -------------------------------------------------------------------------------- 1 | const INXT_URL = "https://internxt.com"; 2 | const INXT_DRIVE_URL = "https://drive.internxt.com"; 3 | 4 | const urls = { 5 | legal: `${INXT_URL}/legal`, 6 | products: { 7 | drive: `${INXT_URL}/drive`, 8 | photos: `${INXT_URL}/photos`, 9 | webdav: `${INXT_URL}/webdav`, 10 | send: "https://send.internxt.com", 11 | pricing: `${INXT_URL}/pricing`, 12 | vpn: `${INXT_URL}/vpn`, 13 | business: `${INXT_URL}/business`, 14 | }, 15 | company: { 16 | about: `${INXT_URL}/about`, 17 | privacy: `${INXT_URL}/privacy`, 18 | security: "https://blog.internxt.com/how-internxt-protects-your-data", 19 | openSource: `${INXT_URL}/open-source`, 20 | mediaArea: `${INXT_URL}/media-area`, 21 | legal: `${INXT_URL}/legal`, 22 | }, 23 | join: { 24 | newsletter: `${INXT_URL}/newsletter-subscribe`, 25 | signup: `${INXT_DRIVE_URL}/new`, 26 | login: `${INXT_DRIVE_URL}/login`, 27 | support: "https://help.internxt.com", 28 | affiliates: "https://internxt.com/affiliates", 29 | github: "https://github.com/internxt", 30 | whitePaper: `/whitepaper/internxt-white-paper.pdf`, 31 | storageForEducation: `${INXT_URL}/cloud-storage-for-education`, 32 | }, 33 | resources: { 34 | blog: "https://blog.internxt.com/", 35 | storageComparison: `${INXT_URL}/cloud-storage-comparison`, 36 | privacyDirectory: `${INXT_URL}/privacy-directory`, 37 | pCloudAlternatives: `${INXT_URL}/pcloud-alternative`, 38 | cyberAwareness: `${INXT_URL}/cyber-awareness`, 39 | whatGoogleKnows: `${INXT_URL}/what-does-google-know-about-me`, 40 | }, 41 | tools: { 42 | byteConverter: `${INXT_URL}/byte-converter`, 43 | tempMail: `${INXT_URL}/temporary-email`, 44 | passwordGenerator: `${INXT_URL}/password-generator`, 45 | passwordChecker: `${INXT_URL}/password-checker`, 46 | virusScanner: `${INXT_URL}/virus-scanner`, 47 | fileConverter: `${INXT_URL}/file-converter`, 48 | }, 49 | social: { 50 | twitter: "https://twitter.com/Internxt", 51 | reddit: "https://www.reddit.com/r/internxt/", 52 | linkedin: "https://linkedin.com/company/internxt", 53 | instagram: "https://instagram.com/internxt", 54 | youtube: 55 | "https://www.youtube.com/channel/UCW2SxWdVEAEACYuejCgpGwg/featured", 56 | }, 57 | }; 58 | 59 | export default urls; 60 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/models/SendItem.ts: -------------------------------------------------------------------------------- 1 | import { FileWithPath } from "react-dropzone"; 2 | 3 | export interface SendItemBasic { 4 | id: string; 5 | name: string; 6 | size: number; 7 | type: 'file' | 'folder'; 8 | parent_folder: string | null; 9 | } 10 | 11 | export interface SendItem extends SendItemBasic { 12 | linkId: string; 13 | networkId: string; 14 | encryptionKey: string; 15 | createdAt: Date; 16 | updatedAt: Date; 17 | version: number; 18 | path?: string; 19 | countFiles?: number; 20 | childrenFiles?: SendItem[]; 21 | childrenFolders?: SendItem[]; 22 | } 23 | 24 | export interface SendItemFile extends SendItemBasic { 25 | file?: FileWithPath; 26 | } 27 | 28 | export interface SendItemFolder extends SendItemBasic { 29 | countFiles?: number; 30 | childrenFiles?: SendItemFile[]; 31 | childrenFolders?: SendItemFolder[]; 32 | } 33 | 34 | export type SendItemData = SendItemFile & SendItemFolder; 35 | -------------------------------------------------------------------------------- /src/network/crypto.ts: -------------------------------------------------------------------------------- 1 | import { ShardMeta } from '@internxt/inxt-js/build/lib/models'; 2 | import { 3 | Aes256gcmEncrypter, 4 | sha512HmacBuffer, 5 | sha512HmacBufferFromHex, 6 | } from '@internxt/inxt-js/build/lib/utils/crypto'; 7 | import { Sha256 } from 'asmcrypto.js'; 8 | import { mnemonicToSeed } from 'bip39'; 9 | import { Cipher, CipherCCM, createCipheriv, createHash } from 'crypto'; 10 | import { streamFileIntoChunks } from './streams'; 11 | 12 | const BUCKET_META_MAGIC = [ 13 | 66, 150, 71, 16, 50, 114, 88, 160, 163, 35, 154, 65, 162, 213, 226, 215, 70, 138, 57, 61, 52, 19, 210, 170, 38, 164, 14 | 162, 200, 86, 201, 2, 81, 15 | ]; 16 | 17 | export function createAES256Cipher(key: Buffer, iv: Buffer): Cipher { 18 | return createCipheriv('aes-256-ctr', key, iv); 19 | } 20 | 21 | export function generateHMAC( 22 | shardMetas: Omit[], 23 | encryptionKey: Buffer, 24 | ): Buffer { 25 | const shardHashesSorted = [...shardMetas].sort((sA, sB) => sA.index - sB.index); 26 | const hmac = sha512HmacBuffer(encryptionKey); 27 | 28 | for (const shardMeta of shardHashesSorted) { 29 | hmac.update(Buffer.from(shardMeta.hash, 'hex')); 30 | } 31 | 32 | return hmac.digest(); 33 | } 34 | 35 | function getDeterministicKey(key: string, data: string): Buffer { 36 | const input = key + data; 37 | 38 | return createHash('sha512').update(Buffer.from(input, 'hex')).digest(); 39 | } 40 | 41 | async function getBucketKey(mnemonic: string, bucketId: string): Promise { 42 | const seed = (await mnemonicToSeed(mnemonic)).toString('hex'); 43 | 44 | return getDeterministicKey(seed, bucketId).toString('hex').slice(0, 64); 45 | } 46 | 47 | export function encryptMeta(fileMeta: string, key: Buffer, iv: Buffer): string { 48 | const cipher: CipherCCM = Aes256gcmEncrypter(key, iv); 49 | const cipherTextBuf = Buffer.concat([cipher.update(fileMeta, 'utf8'), cipher.final()]); 50 | const digest = cipher.getAuthTag(); 51 | 52 | return Buffer.concat([digest, iv, cipherTextBuf]).toString('base64'); 53 | } 54 | 55 | export async function encryptFilename(mnemonic: string, bucketId: string, filename: string): Promise { 56 | const bucketKey = await getBucketKey(mnemonic, bucketId); 57 | const encryptionKey = sha512HmacBufferFromHex(bucketKey).update(Buffer.from(BUCKET_META_MAGIC)).digest().slice(0, 32); 58 | const encryptionIv = sha512HmacBufferFromHex(bucketKey).update(bucketId).update(filename).digest().slice(0, 32); 59 | 60 | return encryptMeta(filename, encryptionKey, encryptionIv); 61 | } 62 | 63 | /** 64 | * Given a stream and a cipher, encrypt its content 65 | * @param readable Readable stream 66 | * @param cipher Cipher used to encrypt the content 67 | * @returns A readable whose output is the encrypted content of the source stream 68 | */ 69 | export function encryptReadable(readable: ReadableStream, cipher: Cipher): ReadableStream { 70 | const reader = readable.getReader(); 71 | 72 | const encryptedFileReadable = new ReadableStream({ 73 | async start(controller) { 74 | let done = false; 75 | 76 | while (!done) { 77 | const status = await reader.read(); 78 | 79 | if (!status.done) { 80 | controller.enqueue(cipher.update(status.value)); 81 | } 82 | 83 | done = status.done; 84 | } 85 | controller.close(); 86 | }, 87 | }); 88 | 89 | return encryptedFileReadable; 90 | } 91 | 92 | export async function getEncryptedFile( 93 | plainFile: { stream(): ReadableStream }, 94 | cipher: Cipher 95 | ): Promise<[Blob, string]> { 96 | const readable = encryptReadable(plainFile.stream(), cipher).getReader(); 97 | const hasher = new Sha256(); 98 | const blobParts: ArrayBuffer[] = []; 99 | 100 | let done = false; 101 | 102 | while (!done) { 103 | const status = await readable.read(); 104 | 105 | if (!status.done) { 106 | hasher.process(status.value); 107 | blobParts.push(status.value); 108 | } 109 | 110 | done = status.done; 111 | } 112 | 113 | hasher.finish(); 114 | 115 | return [ 116 | new Blob(blobParts, { type: 'application/octet-stream' }), 117 | createHash('ripemd160').update(Buffer.from(hasher.result as Uint8Array)).digest('hex'), 118 | ]; 119 | } 120 | 121 | export function sha256(input: Buffer): Buffer { 122 | return createHash('sha256').update(input).digest(); 123 | } 124 | 125 | /** 126 | * Given a stream and a cipher, encrypt its content on pull 127 | * @param readable Readable stream 128 | * @param cipher Cipher used to encrypt the content 129 | * @returns A readable whose output is the encrypted content of the source stream 130 | */ 131 | export function encryptReadablePull(readable: ReadableStream, cipher: Cipher): ReadableStream { 132 | const reader = readable.getReader(); 133 | 134 | return new ReadableStream({ 135 | async pull(controller) { 136 | console.log('2ND_STEP: PULLING'); 137 | const status = await reader.read(); 138 | 139 | if (!status.done) { 140 | controller.enqueue(cipher.update(status.value)); 141 | } else { 142 | controller.close(); 143 | } 144 | }, 145 | }); 146 | } 147 | 148 | export function encryptStreamInParts( 149 | plainFile: { size: number; stream(): ReadableStream }, 150 | cipher: Cipher, 151 | parts: number, 152 | ): ReadableStream { 153 | // We include a marginChunkSize because if we split the chunk directly, there will always be one more chunk left, this will cause a mismatch with the urls provided 154 | const marginChunkSize = 1024; 155 | const chunkSize = plainFile.size / parts + marginChunkSize; 156 | const readableFileChunks = streamFileIntoChunks(plainFile.stream(), chunkSize); 157 | 158 | return encryptReadablePull(readableFileChunks, cipher); 159 | } 160 | 161 | export async function processEveryFileBlobReturnHash( 162 | chunkedFileReadable: ReadableStream, 163 | onEveryBlob: (blob: Blob) => Promise, 164 | ): Promise { 165 | const reader = chunkedFileReadable.getReader(); 166 | const hasher = new Sha256(); 167 | 168 | let done = false; 169 | 170 | while (!done) { 171 | const status = await reader.read(); 172 | if (!status.done) { 173 | const value = status.value; 174 | hasher.process(value); 175 | const blob = new Blob([value], { type: 'application/octet-stream' }); 176 | await onEveryBlob(blob); 177 | } 178 | 179 | done = status.done; 180 | } 181 | 182 | hasher.finish(); 183 | 184 | return createHash('ripemd160') 185 | .update(Buffer.from(hasher.result as Uint8Array)) 186 | .digest('hex'); 187 | } 188 | -------------------------------------------------------------------------------- /src/network/download.ts: -------------------------------------------------------------------------------- 1 | import { Network } from '@internxt/sdk/dist/network'; 2 | import { Decipher } from 'crypto'; 3 | import * as Sentry from '@sentry/react'; 4 | 5 | import { sha256 } from './crypto'; 6 | import { NetworkFacade } from './NetworkFacade'; 7 | import { Abortable } from './requests'; 8 | import { joinReadableBinaryStreams } from './streams'; 9 | 10 | export type DownloadProgressCallback = (totalBytes: number, downloadedBytes: number) => void; 11 | export type Downloadable = { fileId: string; bucketId: string }; 12 | 13 | export function loadWritableStreamPonyfill(): Promise { 14 | const script = document.createElement('script'); 15 | script.src = 'https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js'; 16 | document.head.appendChild(script); 17 | 18 | return new Promise((resolve) => { 19 | script.onload = function () { 20 | resolve(); 21 | }; 22 | }); 23 | } 24 | 25 | type BinaryStream = ReadableStream; 26 | 27 | export async function binaryStreamToBlob(stream: BinaryStream): Promise { 28 | const reader = stream.getReader(); 29 | const slices: Uint8Array[] = []; 30 | 31 | let finish = false; 32 | 33 | while (!finish) { 34 | const { done, value } = await reader.read(); 35 | 36 | if (!done) { 37 | slices.push(value as Uint8Array); 38 | } 39 | 40 | finish = done; 41 | } 42 | 43 | return new Blob(slices); 44 | } 45 | 46 | interface FileInfo { 47 | bucket: string; 48 | mimetype: string; 49 | filename: string; 50 | frame: string; 51 | size: number; 52 | id: string; 53 | created: Date; 54 | hmac: { 55 | value: string; 56 | type: string; 57 | }; 58 | erasure?: { 59 | type: string; 60 | }; 61 | index: string; 62 | } 63 | 64 | export function getDecryptedStream( 65 | encryptedContentSlices: ReadableStream[], 66 | decipher: Decipher, 67 | ): ReadableStream { 68 | const encryptedStream = joinReadableBinaryStreams(encryptedContentSlices); 69 | 70 | let keepReading = true; 71 | 72 | const decryptedStream = new ReadableStream({ 73 | async pull(controller) { 74 | if (!keepReading) return; 75 | 76 | const reader = encryptedStream.getReader(); 77 | const status = await reader.read(); 78 | 79 | if (status.done) { 80 | controller.close(); 81 | } else { 82 | controller.enqueue(decipher.update(status.value)); 83 | } 84 | 85 | reader.releaseLock(); 86 | }, 87 | cancel() { 88 | keepReading = false; 89 | }, 90 | }); 91 | 92 | return decryptedStream; 93 | } 94 | 95 | async function getFileDownloadStream( 96 | downloadUrls: string[], 97 | decipher: Decipher, 98 | abortController?: AbortController, 99 | ): Promise { 100 | const encryptedContentParts: ReadableStream[] = []; 101 | 102 | for (const downloadUrl of downloadUrls) { 103 | const encryptedStream = await fetch(downloadUrl, { signal: abortController?.signal }).then((res) => { 104 | if (!res.body) { 105 | throw new Error('No content received'); 106 | } 107 | 108 | return res.body; 109 | }); 110 | 111 | encryptedContentParts.push(encryptedStream); 112 | } 113 | 114 | return getDecryptedStream(encryptedContentParts, decipher); 115 | } 116 | 117 | interface NetworkCredentials { 118 | user: string; 119 | pass: string; 120 | } 121 | 122 | interface IDownloadParams { 123 | bucketId: string; 124 | fileId: string; 125 | creds?: NetworkCredentials; 126 | mnemonic?: string; 127 | encryptionKey?: Buffer; 128 | token?: string; 129 | options?: { 130 | notifyProgress: DownloadProgressCallback; 131 | abortController?: AbortController; 132 | }; 133 | } 134 | 135 | type FileStream = ReadableStream; 136 | type DownloadFileResponse = Promise; 137 | type DownloadFileOptions = { notifyProgress: DownloadProgressCallback, abortController?: AbortController }; 138 | interface NetworkCredentials { 139 | user: string; 140 | pass: string; 141 | } 142 | 143 | interface DownloadFileParams { 144 | bucketId: string 145 | fileId: string 146 | options?: DownloadFileOptions 147 | } 148 | 149 | interface DownloadOwnFileParams extends DownloadFileParams { 150 | creds: NetworkCredentials 151 | mnemonic: string 152 | token?: never 153 | encryptionKey?: never 154 | } 155 | 156 | interface DownloadSharedFileParams extends DownloadFileParams { 157 | creds?: never 158 | mnemonic?: never 159 | token: string 160 | encryptionKey: string 161 | } 162 | 163 | type DownloadSharedFileFunction = (params: DownloadSharedFileParams) => DownloadFileResponse; 164 | type DownloadOwnFileFunction = (params: DownloadOwnFileParams) => DownloadFileResponse; 165 | type DownloadFileFunction = (params: DownloadSharedFileParams | DownloadOwnFileParams) => DownloadFileResponse; 166 | 167 | const downloadSharedFile: DownloadSharedFileFunction = (params) => { 168 | const { bucketId, fileId, encryptionKey, token, options } = params; 169 | 170 | return new NetworkFacade( 171 | Network.client( 172 | process.env.REACT_APP_NETWORK_URL as string, 173 | { 174 | clientName: 'drive-web', 175 | clientVersion: '1.0' 176 | }, 177 | { 178 | bridgeUser: '', 179 | userId: '' 180 | } 181 | ) 182 | ).download(bucketId, fileId, '', { 183 | key: Buffer.from(encryptionKey, 'hex'), 184 | token, 185 | downloadingCallback: options?.notifyProgress, 186 | abortController: options?.abortController 187 | }); 188 | }; 189 | 190 | function getAuthFromCredentials(creds: NetworkCredentials): { username: string, password: string } { 191 | return { 192 | username: creds.user, 193 | password: sha256(Buffer.from(creds.pass)).toString('hex'), 194 | }; 195 | } 196 | 197 | const downloadOwnFile: DownloadOwnFileFunction = (params) => { 198 | const { bucketId, fileId, mnemonic, options } = params; 199 | const auth = getAuthFromCredentials(params.creds); 200 | 201 | return new NetworkFacade( 202 | Network.client( 203 | process.env.REACT_APP_NETWORK_URL as string, 204 | { 205 | clientName: 'drive-web', 206 | clientVersion: '1.0' 207 | }, 208 | { 209 | bridgeUser: auth.username, 210 | userId: auth.password 211 | } 212 | ) 213 | ).download(bucketId, fileId, mnemonic, { 214 | downloadingCallback: options?.notifyProgress, 215 | abortController: options?.abortController 216 | }); 217 | }; 218 | 219 | const downloadFile: DownloadFileFunction = (params) => { 220 | if (params.token && params.encryptionKey) { 221 | return downloadSharedFile(params); 222 | } else if (params.creds && params.mnemonic) { 223 | return downloadOwnFile(params); 224 | } else { 225 | throw new Error('DOWNLOAD ERRNO. 0'); 226 | } 227 | }; 228 | 229 | export default downloadFile; 230 | -------------------------------------------------------------------------------- /src/network/requests.ts: -------------------------------------------------------------------------------- 1 | export interface Abortable { 2 | abort: () => void; 3 | } 4 | -------------------------------------------------------------------------------- /src/network/streams.ts: -------------------------------------------------------------------------------- 1 | type BinaryStream = ReadableStream; 2 | 3 | export async function binaryStreamToBlob(stream: BinaryStream): Promise { 4 | const reader = stream.getReader(); 5 | const slices: Uint8Array[] = []; 6 | 7 | let finish = false; 8 | 9 | while (!finish) { 10 | const { done, value } = await reader.read(); 11 | 12 | if (!done) { 13 | slices.push(value as Uint8Array); 14 | } 15 | 16 | finish = done; 17 | } 18 | 19 | return new Blob(slices); 20 | } 21 | 22 | export function buildProgressStream(source: BinaryStream, onRead: (readBytes: number) => void): BinaryStream { 23 | const reader = source.getReader(); 24 | let readBytes = 0; 25 | 26 | return new ReadableStream({ 27 | async pull(controller) { 28 | const status = await reader.read(); 29 | 30 | if (status.done) { 31 | controller.close(); 32 | } else { 33 | readBytes += (status.value as Uint8Array).length; 34 | 35 | onRead(readBytes); 36 | controller.enqueue(status.value); 37 | } 38 | }, 39 | cancel() { 40 | return reader.cancel(); 41 | } 42 | }); 43 | } 44 | 45 | export function joinReadableBinaryStreams(streams: BinaryStream[]): ReadableStream { 46 | const streamsCopy = streams.map(s => s); 47 | let keepReading = true; 48 | 49 | const flush = () => streamsCopy.forEach(s => s.cancel()); 50 | 51 | const stream = new ReadableStream({ 52 | async pull(controller) { 53 | if (!keepReading) return flush(); 54 | 55 | const downStream = streamsCopy.shift(); 56 | 57 | if (!downStream) { 58 | return controller.close(); 59 | } 60 | 61 | const reader = downStream.getReader(); 62 | let done = false; 63 | 64 | while (!done && keepReading) { 65 | const status = await reader.read(); 66 | 67 | if (!status.done) { 68 | controller.enqueue(status.value); 69 | } 70 | 71 | done = status.done; 72 | } 73 | 74 | reader.releaseLock(); 75 | }, 76 | cancel() { 77 | keepReading = false; 78 | } 79 | }); 80 | 81 | return stream; 82 | } 83 | 84 | /** 85 | * Given a stream of a file, it will read it and enqueue its parts in chunkSizes 86 | * @param readable Readable stream 87 | * @param chunkSize The chunkSize in bytes that we want each chunk to be 88 | * @returns A readable whose output is chunks of the file of size chunkSize 89 | */ 90 | export function streamFileIntoChunks( 91 | readable: ReadableStream, 92 | chunkSize: number, 93 | ): ReadableStream { 94 | const reader = readable.getReader(); 95 | let buffer = new Uint8Array(0); 96 | 97 | return new ReadableStream({ 98 | async pull(controller) { 99 | function handleDone() { 100 | if (buffer.byteLength > 0) { 101 | controller.enqueue(buffer); 102 | } 103 | return controller.close(); 104 | } 105 | 106 | const status = await reader.read(); 107 | 108 | if (status.done) return handleDone(); 109 | 110 | const chunk = status.value; 111 | buffer = mergeBuffers(buffer, chunk); 112 | 113 | while (buffer.byteLength < chunkSize) { 114 | const status = await reader.read(); 115 | 116 | if (status.done) return handleDone(); 117 | 118 | buffer = mergeBuffers(buffer, status.value); 119 | } 120 | 121 | controller.enqueue(buffer.slice(0, chunkSize)); 122 | buffer = new Uint8Array(buffer.slice(chunkSize)); 123 | }, 124 | }); 125 | } 126 | 127 | function mergeBuffers(buffer1: Uint8Array, buffer2: Uint8Array): Uint8Array { 128 | const mergedBuffer = new Uint8Array(buffer1.length + buffer2.length); 129 | mergedBuffer.set(buffer1); 130 | mergedBuffer.set(buffer2, buffer1.length); 131 | return mergedBuffer; 132 | } 133 | -------------------------------------------------------------------------------- /src/network/upload.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/react"; 2 | import { Network } from "@internxt/sdk/dist/network"; 3 | import { ErrorWithContext } from "@internxt/sdk/dist/network/errors"; 4 | 5 | import { sha256 } from "./crypto"; 6 | import { NetworkFacade } from "./NetworkFacade"; 7 | import { reportError } from "../services/error-reporting.service"; 8 | 9 | export type UploadProgressCallback = ( 10 | totalBytes: number, 11 | uploadedBytes: number 12 | ) => void; 13 | 14 | interface NetworkCredentials { 15 | user: string; 16 | pass: string; 17 | } 18 | 19 | interface IUploadParams { 20 | filesize: number; 21 | filecontent: File; 22 | creds: NetworkCredentials; 23 | mnemonic: string; 24 | progressCallback: UploadProgressCallback; 25 | abortController?: AbortController; 26 | parts?: number; 27 | } 28 | 29 | export function uploadFileBlob( 30 | encryptedFile: Blob, 31 | url: string, 32 | opts: { 33 | progressCallback: UploadProgressCallback; 34 | abortController?: AbortController; 35 | } 36 | ): Promise { 37 | const uploadRequest = new XMLHttpRequest(); 38 | 39 | if (opts.abortController?.signal && opts.abortController.signal.aborted) { 40 | throw new Error("Upload aborted"); 41 | } 42 | 43 | opts.abortController?.signal.addEventListener( 44 | "abort", 45 | () => { 46 | uploadRequest.abort(); 47 | }, 48 | { once: true } 49 | ); 50 | 51 | uploadRequest.upload.addEventListener("progress", (e) => { 52 | opts.progressCallback(e.total, e.loaded); 53 | }); 54 | uploadRequest.upload.addEventListener("loadstart", (e) => 55 | opts.progressCallback(e.total, 0) 56 | ); 57 | uploadRequest.upload.addEventListener("loadend", (e) => 58 | opts.progressCallback(e.total, e.total) 59 | ); 60 | 61 | const uploadFinishedPromise = new Promise( 62 | (resolve, reject) => { 63 | uploadRequest.onload = () => { 64 | if (uploadRequest.status !== 200) { 65 | return reject( 66 | new Error( 67 | "Upload failed with code " + 68 | uploadRequest.status + 69 | " message " + 70 | uploadRequest.response 71 | ) 72 | ); 73 | } 74 | resolve(uploadRequest); 75 | }; 76 | uploadRequest.onerror = reject; 77 | uploadRequest.onabort = () => reject(new Error("Upload aborted")); 78 | uploadRequest.ontimeout = () => reject(new Error("Request timeout")); 79 | } 80 | ); 81 | 82 | uploadRequest.open("PUT", url); 83 | // ! Uncomment this line for multipart to work: 84 | // uploadRequest.setRequestHeader('Content-Type', ''); 85 | uploadRequest.send(encryptedFile); 86 | 87 | return uploadFinishedPromise; 88 | } 89 | 90 | function getAuthFromCredentials(creds: NetworkCredentials): { 91 | username: string; 92 | password: string; 93 | } { 94 | return { 95 | username: creds.user, 96 | password: sha256(Buffer.from(creds.pass)).toString("hex"), 97 | }; 98 | } 99 | 100 | export function uploadFile( 101 | bucketId: string, 102 | params: IUploadParams 103 | ): Promise { 104 | const file: File = params.filecontent; 105 | 106 | const auth = getAuthFromCredentials({ 107 | user: params.creds.user, 108 | pass: params.creds.pass, 109 | }); 110 | 111 | const facade = new NetworkFacade( 112 | Network.client( 113 | process.env.REACT_APP_NETWORK_URL as string, 114 | { 115 | clientName: "drive-web", 116 | clientVersion: "1.0", 117 | }, 118 | { 119 | bridgeUser: auth.username, 120 | userId: auth.password, 121 | } 122 | ) 123 | ); 124 | 125 | if (params.parts) { 126 | return facade 127 | .uploadMultipart(bucketId, params.mnemonic, file, { 128 | uploadingCallback: params.progressCallback, 129 | abortController: params.abortController, 130 | parts: params.parts, 131 | }) 132 | .catch((err: ErrorWithContext) => { 133 | reportError(err, err.context); 134 | throw err; 135 | }); 136 | } 137 | 138 | return facade 139 | .upload(bucketId, params.mnemonic, file, { 140 | uploadingCallback: params.progressCallback, 141 | abortController: params.abortController, 142 | }) 143 | .catch((err: ErrorWithContext) => { 144 | reportError(err, err.context); 145 | 146 | throw err; 147 | }); 148 | } 149 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | NODE_ENV: "development" | "production" | "test"; 6 | PUBLIC_URL: string; 7 | REACT_APP_SEND_API_URL: string; 8 | REACT_APP_CRYPTO_SECRET: string; 9 | REACT_APP_CRYPTO_SECRET2: string; 10 | REACT_APP_NETWORK_URL: string; 11 | REACT_APP_PROXY: string; 12 | 13 | REACT_APP_SEND_USER: string; 14 | REACT_APP_SEND_PASS: string; 15 | REACT_APP_SEND_ENCRYPTION_KEY: string; 16 | REACT_APP_SEND_BUCKET_ID: string; 17 | REACT_APP_BASE_URL: string; 18 | } 19 | } 20 | 21 | interface Window { 22 | grecaptcha: { 23 | ready: (cb: () => void) => void; 24 | execute: (siteKey: string, { action: string }) => Promise; 25 | }; 26 | gtag: any; 27 | rudderanalytics: any; 28 | } 29 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/services/analytics.service.ts: -------------------------------------------------------------------------------- 1 | const CONTEXT_APP_NAME = "send-web"; 2 | 3 | const track = (eventName: string, dataToSend: Record) => { 4 | window.rudderanalytics.track(eventName, dataToSend, { 5 | context: { 6 | app: { 7 | name: CONTEXT_APP_NAME, 8 | }, 9 | }, 10 | }); 11 | }; 12 | 13 | const analyticsService = { 14 | track, 15 | }; 16 | 17 | export default analyticsService; 18 | -------------------------------------------------------------------------------- /src/services/download.service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import fileDownload from "js-file-download"; 3 | 4 | import { NetworkService, ProgressOptions } from "./network.service"; 5 | import { FlatFolderZip } from "./zip/FlatFolderZip"; 6 | 7 | import { binaryStreamToBlob } from "../network/streams"; 8 | import { SendItem } from "../models/SendItem"; 9 | import { StreamService } from "./stream.service"; 10 | import { getAllItemsList } from "./items.service"; 11 | 12 | 13 | /** 14 | * This service has *the only responsability* of downloading 15 | * the content via browser to the user filesystem. 16 | */ 17 | export class DownloadService { 18 | static async downloadFilesFromLink( 19 | linkId: string, 20 | opts?: ProgressOptions 21 | ): Promise { 22 | const { title, items, code } = await getSendLink(linkId); 23 | const date = new Date(); 24 | const now = date.getFullYear() + String(date.getMonth() + 1).padStart(2, '0') 25 | + String(date.getDate()).padStart(2, '0') + String(date.getHours()).padStart(2, '0') 26 | + String(date.getMinutes()).padStart(2, '0') + String(date.getSeconds()).padStart(2, '0') 27 | + String(date.getMilliseconds()).padStart(3, '0'); 28 | 29 | await DownloadService.downloadFiles( 30 | title && String(title).trim().length > 0 ? title : 'internxt-send_' + now, 31 | items, 32 | NetworkService.getInstance(), 33 | opts?.plainCode || code, 34 | opts 35 | ); 36 | } 37 | 38 | static async downloadFiles( 39 | zipName: string, 40 | items: SendItem[], 41 | networkService: NetworkService, 42 | plainCode: string, 43 | opts?: ProgressOptions 44 | ) { 45 | const totalBytes = items.reduce((a, f) => a + (f.type === 'file' ? f.size : 0), 0); 46 | 47 | const itemList = getAllItemsList(items); 48 | 49 | const options = { 50 | progress: (downloadedBytes: number) => { 51 | opts?.progress?.(opts.totalBytes || totalBytes, Math.min(downloadedBytes, opts.totalBytes || totalBytes)); 52 | }, 53 | abortController: opts?.abortController 54 | }; 55 | 56 | if (itemList.length > 1 || (itemList.length === 1 && itemList[0].type === 'folder')) { 57 | const zip = new FlatFolderZip(zipName, options); 58 | 59 | for (const item of itemList) { 60 | await this.addItemToZip(zip, item, networkService, plainCode, opts); 61 | } 62 | 63 | await zip.close(); 64 | } else { 65 | const [firstItem] = itemList; 66 | if (firstItem.type === 'file') { 67 | const itemDownloadStream = await networkService.getDownloadFileStream( 68 | firstItem, 69 | plainCode, 70 | opts 71 | ); 72 | 73 | const oneGigabyte = 1 * 1024 * 1024 * 1024; 74 | if (firstItem.size > oneGigabyte) { 75 | await StreamService.pipeReadableToFileSystemStream( 76 | itemDownloadStream, 77 | firstItem.name, 78 | options 79 | ) 80 | } else { 81 | fileDownload( 82 | await binaryStreamToBlob(itemDownloadStream), 83 | firstItem.name 84 | ); 85 | } 86 | } else if (firstItem.type === 'folder') { 87 | const zip = new FlatFolderZip(firstItem.name, options); 88 | await this.addItemToZip(zip, firstItem, networkService, plainCode, opts); 89 | await zip.close(); 90 | } 91 | } 92 | } 93 | 94 | static async addItemToZip(zip: FlatFolderZip, item: SendItem, 95 | networkService: NetworkService, 96 | plainCode: string, 97 | opts?: ProgressOptions) { 98 | if (item.type === 'file') { 99 | const itemDownloadStream = await networkService.getDownloadFileStream( 100 | item, 101 | plainCode 102 | ); 103 | zip.addFile(item.path || item.name, itemDownloadStream); 104 | } else if (item.type === 'folder') { 105 | zip.addFolder(item.path || item.name); 106 | } 107 | if (item.childrenFiles && item.childrenFiles.length > 0) { 108 | await Promise.all(item.childrenFiles.map((childrenFile) => { 109 | return this.addItemToZip(zip, childrenFile, networkService, plainCode, opts); 110 | })); 111 | } 112 | 113 | if (item.childrenFolders && item.childrenFolders.length > 0) { 114 | await Promise.all(item.childrenFolders.map((childrenFolder) => { 115 | return this.addItemToZip(zip, childrenFolder, networkService, plainCode, opts); 116 | })); 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * TODO: SDK 123 | */ 124 | export interface GetSendLinkResponse { 125 | id: string; 126 | title: string; 127 | subject: string; 128 | code: string; 129 | views: number; 130 | userId: number | null; 131 | items: SendItem[]; 132 | createdAt: string; 133 | updatedAt: string; 134 | expirationAt: string; 135 | size: number; 136 | } 137 | 138 | export async function getSendLink( 139 | linkId: string 140 | ): Promise { 141 | const res = await axios.get( 142 | process.env.REACT_APP_SEND_API_URL + "/links/" + linkId 143 | ); 144 | 145 | return res.data; 146 | } 147 | -------------------------------------------------------------------------------- /src/services/error-reporting.service.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/react"; 2 | 3 | export function reportError(exception: any, extra: Record) { 4 | Sentry.captureException(exception, { extra }); 5 | } 6 | -------------------------------------------------------------------------------- /src/services/network.service.ts: -------------------------------------------------------------------------------- 1 | import downloadFile from "../network/download"; 2 | import { uploadFile } from "../network/upload"; 3 | import axios, { AxiosRequestConfig } from 'axios'; 4 | import { createHash } from 'crypto'; 5 | import { aes } from "@internxt/lib"; 6 | import { SendItem } from "../models/SendItem"; 7 | 8 | interface NetworkCredentials { 9 | user: string; 10 | pass: string; 11 | } 12 | 13 | function getSendAccountParameters(): { 14 | bucketId: string, 15 | user: string, 16 | pass: string, 17 | encryptionKey: string 18 | } { 19 | return { 20 | bucketId: process.env.REACT_APP_SEND_BUCKET_ID, 21 | user: process.env.REACT_APP_SEND_USER, 22 | pass: process.env.REACT_APP_SEND_PASS, 23 | encryptionKey: process.env.REACT_APP_SEND_ENCRYPTION_KEY 24 | } 25 | } 26 | 27 | export type ProgressOptions = { 28 | totalBytes?: number; 29 | progress?: (totalBytes: number, downloadedBytes: number) => void; 30 | abortController?: AbortController; 31 | plainCode?: string; 32 | }; 33 | 34 | /** 35 | * This service has *the only responsability* of providing access 36 | * to the content of Send ites, knowing how Send is using the Internxt's 37 | * Network and how the zero-knowledge is being implemented. 38 | */ 39 | export class NetworkService { 40 | private constructor(private readonly creds: NetworkCredentials) { } 41 | public static getInstance(): NetworkService { 42 | const accountParams = getSendAccountParameters(); 43 | 44 | return new NetworkService({ 45 | user: accountParams.user, 46 | pass: accountParams.pass 47 | }); 48 | } 49 | 50 | /** 51 | * Downloads a Send item using the decrypting protocol version 1. 52 | */ 53 | getDownloadFileStreamV1( 54 | item: SendItem, 55 | encryptedCode: string, 56 | opts?: ProgressOptions 57 | ): Promise { 58 | const { bucketId, encryptionKey } = getSendAccountParameters(); 59 | const plainCode = aes.decrypt(encryptedCode, encryptionKey); 60 | 61 | return downloadFile({ 62 | bucketId, 63 | fileId: item.networkId, 64 | creds: this.creds, 65 | mnemonic: aes.decrypt(item.encryptionKey, plainCode), 66 | options: { 67 | notifyProgress: (totalBytes, downloadedBytes) => { 68 | opts?.progress?.(opts.totalBytes || totalBytes, downloadedBytes); 69 | }, 70 | abortController: opts?.abortController 71 | } 72 | }); 73 | } 74 | 75 | /** 76 | * Downloads a Send item using the decrypting protocol version 2. 77 | */ 78 | getDownloadFileStreamV2( 79 | item: SendItem, 80 | plainCode: string, 81 | opts?: ProgressOptions 82 | ): Promise { 83 | const { bucketId } = getSendAccountParameters(); 84 | const encryptionKey = aes.decrypt(item.encryptionKey, plainCode); 85 | 86 | return downloadFile({ 87 | bucketId, 88 | fileId: item.networkId, 89 | creds: this.creds, 90 | mnemonic: encryptionKey, 91 | options: { 92 | notifyProgress: (totalBytes, downloadedBytes) => { 93 | opts?.progress?.(opts.totalBytes || totalBytes, downloadedBytes); 94 | }, 95 | abortController: opts?.abortController 96 | } 97 | }); 98 | } 99 | 100 | public getDownloadFileStream( 101 | item: SendItem, 102 | code: string, 103 | opts?: ProgressOptions & { customEncryptionKey?: string } 104 | ): Promise { 105 | const requiresVersionTwoDecryption = item.version === 2; 106 | 107 | if (requiresVersionTwoDecryption) { 108 | return this.getDownloadFileStreamV2( 109 | item, 110 | code, 111 | opts 112 | ); 113 | } else { 114 | return this.getDownloadFileStreamV1( 115 | item, 116 | code, 117 | opts 118 | ); 119 | } 120 | } 121 | 122 | get encryptionKey(): string { 123 | return process.env.REACT_APP_SEND_ENCRYPTION_KEY; 124 | } 125 | 126 | async uploadFile( 127 | file: File, 128 | opts?: { 129 | progress?: (totalBytes: number, downloadedBytes: number) => void, 130 | abortController?: AbortController 131 | } 132 | ): Promise { 133 | const { bucketId, user, pass, encryptionKey } = getSendAccountParameters(); 134 | 135 | let parts 136 | const partSize = 50 * 1024 * 1024; 137 | const minimumMultipartThreshold = 100 * 1024 * 1024; 138 | 139 | if (file.size > minimumMultipartThreshold) { 140 | parts = Math.ceil(file.size / partSize); 141 | } 142 | 143 | return uploadFile(bucketId, { 144 | creds: { user, pass }, 145 | filecontent: file, 146 | filesize: file.size, 147 | mnemonic: encryptionKey, 148 | progressCallback: (totalBytes, uploadedBytes) => { 149 | opts?.progress?.(totalBytes, uploadedBytes); 150 | }, 151 | parts, 152 | abortController: opts?.abortController 153 | }); 154 | } 155 | 156 | async getShareToken(): Promise { 157 | // TODO: Move to SDK 158 | const bucketId = process.env.REACT_APP_SEND_BUCKET_ID; 159 | const operation = 'PULL'; 160 | const requestUrl = `${process.env.REACT_APP_NETWORK_URL}/buckets/${bucketId}/tokens`; 161 | 162 | function sha256(input: Buffer): Buffer { 163 | return createHash('sha256').update(input).digest(); 164 | } 165 | 166 | const opts: AxiosRequestConfig = { 167 | method: 'POST', 168 | auth: { 169 | username: process.env.REACT_APP_SEND_USER, 170 | password: sha256(Buffer.from(process.env.REACT_APP_SEND_PASS)).toString('hex'), 171 | }, 172 | url: requestUrl, 173 | data: { operation } 174 | }; 175 | 176 | const res = await axios.request<{ 177 | bucket: string 178 | encryptionKey: string, 179 | id: string, 180 | operation: 'PULL', 181 | token: string; 182 | }>(opts); 183 | 184 | return res.data.token; 185 | } 186 | 187 | async uploadFileForShare( 188 | file: File, 189 | opts?: { 190 | progress?: (totalBytes: number, downloadedBytes: number) => void, 191 | abortController?: AbortController 192 | } 193 | ): Promise { 194 | const { bucketId, user, pass, encryptionKey } = getSendAccountParameters(); 195 | 196 | let parts 197 | const partSize = 100 * 1024 * 1024; 198 | const minimumMultipartThreshold = 100 * 1024 * 1024; 199 | 200 | if (file.size > minimumMultipartThreshold) { 201 | parts = Math.ceil(file.size / partSize); 202 | } 203 | 204 | return uploadFile(bucketId, { 205 | creds: { user, pass }, 206 | filecontent: file, 207 | filesize: file.size, 208 | mnemonic: encryptionKey, 209 | progressCallback: (totalBytes, uploadedBytes) => { 210 | opts?.progress?.(totalBytes, uploadedBytes); 211 | }, 212 | parts, 213 | abortController: opts?.abortController 214 | }); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/services/notifications.service.ts: -------------------------------------------------------------------------------- 1 | import { createElement } from "react"; 2 | import toast from "react-hot-toast"; 3 | import NotificationToast from "../components/NotificationToast"; 4 | 5 | export enum ToastType { 6 | Success = "success", 7 | Error = "error", 8 | Warning = "warning", 9 | Info = "info", 10 | } 11 | 12 | export type ToastShowProps = { 13 | text: string; 14 | type?: ToastType; 15 | action?: { text: string; onClick: () => void }; 16 | duration?: number; 17 | closable?: boolean; 18 | }; 19 | 20 | const notificationsService = { 21 | show: ({ 22 | text, 23 | type, 24 | action, 25 | duration = 5000, 26 | closable = true, 27 | }: ToastShowProps): string => { 28 | const id = toast.custom( 29 | (t) => 30 | createElement(NotificationToast, { 31 | text, 32 | type, 33 | visible: t.visible, 34 | action, 35 | closable, 36 | onClose() { 37 | toast.dismiss(id); 38 | }, 39 | }), 40 | { duration } 41 | ); 42 | return id; 43 | }, 44 | dismiss: toast.dismiss, 45 | }; 46 | 47 | export default notificationsService; 48 | -------------------------------------------------------------------------------- /src/services/stream.service.ts: -------------------------------------------------------------------------------- 1 | import fileDownload from 'js-file-download'; 2 | import streamSaver from 'streamsaver'; 3 | 4 | import { loadWritableStreamPonyfill } from "../network/download"; 5 | import { binaryStreamToBlob, buildProgressStream } from '../network/streams'; 6 | 7 | function isBrave() { 8 | const maybeBrave = (window.navigator as { brave?: any }).brave; 9 | 10 | return maybeBrave !== undefined && maybeBrave.isBrave.name === "isBrave"; 11 | } 12 | 13 | export class StreamService { 14 | static async pipeReadableToFileSystemStream( 15 | readable: ReadableStream, 16 | fileSystemName: string, 17 | opts: { 18 | progress: (readBytes: number) => void, 19 | abortController?: AbortController 20 | } 21 | ): Promise { 22 | const { progress } = opts; 23 | const passThrough = progress ? buildProgressStream(readable, progress) : readable; 24 | 25 | if (isBrave()) { 26 | fileDownload(await binaryStreamToBlob(passThrough), fileSystemName); 27 | return; 28 | } 29 | 30 | const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1; 31 | if (isFirefox) { 32 | await loadWritableStreamPonyfill(); 33 | 34 | streamSaver.WritableStream = window.WritableStream; 35 | } 36 | 37 | await passThrough.pipeTo( 38 | streamSaver.createWriteStream(fileSystemName), 39 | { signal: opts.abortController?.signal } 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/services/upload.service.test.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, it, expect, beforeEach, Mock } from "vitest"; 2 | import { getCaptchaToken } from "../lib/auth"; 3 | import { 4 | CreateSendLinksResponse, 5 | SendLink, 6 | UploadService, 7 | } from "./upload.service"; 8 | import { FileWithPath } from "react-dropzone"; 9 | import { SendItemData } from "../models/SendItem"; 10 | import axios from "axios"; 11 | 12 | vi.mock("axios"); 13 | 14 | vi.mock("../lib/auth", () => ({ 15 | getCaptchaToken: vi.fn().mockResolvedValue("mock-token"), 16 | })); 17 | 18 | vi.mock("./upload.service", async () => { 19 | const mod = await vi.importActual( 20 | "./upload.service" 21 | ); 22 | return { 23 | ...mod, 24 | }; 25 | }); 26 | 27 | const mockSendLink: SendLink = { 28 | id: "mock-id-12345", 29 | name: "example-file.txt", 30 | type: "file", 31 | size: 1024, 32 | networkId: "network-id-67890", 33 | encryptionKey: "encryption-key-abcdef", 34 | parent_folder: null, 35 | }; 36 | 37 | const mockSendLinkFolder: SendLink = { 38 | id: "mock-id-54321", 39 | name: "example-folder", 40 | type: "folder", 41 | size: 0, 42 | networkId: "network-id-09876", 43 | encryptionKey: "encryption-key-zyxwvu", 44 | parent_folder: "parent-folder-id", 45 | }; 46 | 47 | const mockCreateSendLinksResponse: CreateSendLinksResponse = { 48 | id: "mock-id-12345", 49 | title: "Example Send Link", 50 | subject: "Example Subject", 51 | code: "mock-code-abcdef", 52 | sender: "mock-sender@example.com", 53 | receivers: ["receiver1@example.com", "receiver2@example.com"], 54 | views: 5, 55 | userId: 42, 56 | items: [ 57 | { 58 | id: "item-id-1", 59 | name: "example-file.txt", 60 | type: "file", 61 | size: 1024, // 1 KB 62 | networkId: "network-id-67890", 63 | encryptionKey: "encryption-key-abcdef", 64 | parent_folder: null, 65 | }, 66 | { 67 | id: "item-id-2", 68 | name: "example-folder", 69 | type: "folder", 70 | size: 0, 71 | networkId: "network-id-09876", 72 | encryptionKey: "encryption-key-zyxwvu", 73 | parent_folder: "parent-folder-id", 74 | }, 75 | ], 76 | createdAt: new Date().toISOString(), 77 | updatedAt: new Date().toISOString(), 78 | expirationAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), 79 | }; 80 | 81 | beforeEach(() => { 82 | vi.clearAllMocks(); 83 | (axios.post as Mock).mockResolvedValue({ data: mockCreateSendLinksResponse }); 84 | }); 85 | 86 | describe("upload.service", () => { 87 | it("When the uploadFiles() is done, then should call storeSendLinks() and ensure getCaptchaToken() is called once", async () => { 88 | const uploadFilesSpy = vi.spyOn(UploadService, "uploadFiles"); 89 | const storeSendLinksSpy = vi.spyOn(UploadService, "storeSendLinks"); 90 | (getCaptchaToken as Mock).mockResolvedValue("mock-token"); 91 | 92 | const axiosPostSpy = vi.spyOn(axios, "post"); 93 | 94 | uploadFilesSpy.mockResolvedValue([mockSendLink, mockSendLinkFolder]); 95 | 96 | const result = await UploadService.uploadFilesAndGetLink([ 97 | { 98 | id: "1", 99 | name: "file1.txt", 100 | type: "file", 101 | size: 100, 102 | file: {} as unknown as FileWithPath, 103 | } as unknown as SendItemData, 104 | { 105 | id: "2", 106 | name: "file2.txt", 107 | type: "file", 108 | size: 200, 109 | file: {} as unknown as FileWithPath, 110 | } as unknown as SendItemData, 111 | ]); 112 | 113 | expect(uploadFilesSpy).toHaveBeenCalledTimes(1); 114 | expect(getCaptchaToken).toHaveBeenCalledTimes(1); 115 | expect(storeSendLinksSpy).toHaveBeenCalledTimes(1); 116 | expect(storeSendLinksSpy).toHaveBeenCalledWith( 117 | expect.objectContaining({ 118 | items: expect.any(Array), 119 | code: expect.any(String), 120 | mnemonic: expect.any(String), 121 | plainCode: expect.any(String), 122 | }) 123 | ); 124 | expect(axiosPostSpy).toHaveBeenCalledTimes(1); 125 | expect(uploadFilesSpy.mock.invocationCallOrder[0]).toBeLessThan( 126 | (getCaptchaToken as Mock).mock.invocationCallOrder[0] 127 | ); 128 | expect((getCaptchaToken as Mock).mock.invocationCallOrder[0]).toBeLessThan( 129 | axiosPostSpy.mock.invocationCallOrder[0] 130 | ); 131 | expect(result).toContain("/download/"); 132 | expect(result).toContain("?code="); 133 | }); 134 | 135 | it("When links are sent to be created, the captcha is generated before that", async () => { 136 | const storeSendLinksSpy = vi.spyOn(UploadService, "storeSendLinks"); 137 | const axiosPostSpy = vi.spyOn(axios, "post"); 138 | (getCaptchaToken as Mock).mockResolvedValue("mock-token"); 139 | vi.spyOn(UploadService, "uploadFiles").mockResolvedValue([mockSendLink]); 140 | 141 | await UploadService.uploadFilesAndGetLink([ 142 | { 143 | id: "1", 144 | name: "file1.txt", 145 | type: "file", 146 | size: 100, 147 | file: {} as unknown as FileWithPath, 148 | } as unknown as SendItemData, 149 | ]); 150 | 151 | expect(getCaptchaToken).toHaveBeenCalled(); 152 | expect(storeSendLinksSpy).toHaveBeenCalled(); 153 | expect(storeSendLinksSpy.mock.invocationCallOrder[0]).toBeLessThan( 154 | (getCaptchaToken as Mock).mock.invocationCallOrder[0] 155 | ); 156 | expect((getCaptchaToken as Mock).mock.invocationCallOrder[0]).toBeLessThan( 157 | axiosPostSpy.mock.invocationCallOrder[0] 158 | ); 159 | }); 160 | 161 | it("When an error is thrown before getCaptchaToken, then getCaptchaToken is not called", async () => { 162 | const uploadFilesSpy = vi 163 | .spyOn(UploadService, "uploadFiles") 164 | .mockImplementation(() => { 165 | throw new Error("Error before captcha"); 166 | }); 167 | 168 | await expect( 169 | UploadService.uploadFilesAndGetLink([ 170 | { 171 | id: "1", 172 | name: "file1.txt", 173 | type: "file", 174 | size: 100, 175 | file: {} as unknown as FileWithPath, 176 | } as unknown as SendItemData, 177 | ]) 178 | ).rejects.toThrow("Error before captcha"); 179 | 180 | expect(uploadFilesSpy).toHaveBeenCalled(); 181 | expect(UploadService.storeSendLinks).not.toHaveBeenCalled(); 182 | expect(getCaptchaToken).not.toHaveBeenCalled(); 183 | }); 184 | 185 | it("When an error is thrown in getCaptchaToken, then storeSendLinks is not called", async () => { 186 | vi.spyOn(UploadService, "uploadFiles").mockResolvedValue([mockSendLink]); 187 | (getCaptchaToken as Mock).mockRejectedValue(new Error("Captcha error")); 188 | const axiosPostSpy = vi.spyOn(axios, "post"); 189 | 190 | await expect(UploadService.storeSendLinks).rejects.toThrow("Captcha error"); 191 | 192 | expect(getCaptchaToken).toHaveBeenCalled(); 193 | expect(axiosPostSpy).not.toHaveBeenCalled(); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /src/services/zip/FlatFolderZip.ts: -------------------------------------------------------------------------------- 1 | import streamSaver from 'streamsaver'; 2 | import fileDownload from 'js-file-download'; 3 | 4 | import { binaryStreamToBlob, buildProgressStream } from '../../network/streams'; 5 | import { loadWritableStreamPonyfill, createFolderWithFilesWritable, ZipStream } from "./Zip"; 6 | 7 | type FlatFolderZipOpts = { 8 | abortController?: AbortController; 9 | progress?: (loadedBytes: number) => void; 10 | } 11 | function isBrave() { 12 | const maybeBrave = (window.navigator as { brave?: any }).brave; 13 | 14 | return maybeBrave !== undefined && maybeBrave.isBrave.name === "isBrave"; 15 | } 16 | export class FlatFolderZip { 17 | private finished!: Promise; 18 | private zip: ZipStream; 19 | private passThrough: ReadableStream; 20 | private folderName: string; 21 | private abortController?: AbortController; 22 | 23 | constructor(folderName: string, opts: FlatFolderZipOpts) { 24 | this.folderName = folderName; 25 | this.zip = createFolderWithFilesWritable(); 26 | this.abortController = opts.abortController; 27 | 28 | this.passThrough = opts.progress ? 29 | buildProgressStream(this.zip.stream, opts.progress) : 30 | this.zip.stream; 31 | 32 | const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1; 33 | 34 | if (isBrave()) return; 35 | 36 | if (isFirefox) { 37 | loadWritableStreamPonyfill().then(() => { 38 | streamSaver.WritableStream = window.WritableStream; 39 | 40 | this.finished = this.passThrough.pipeTo( 41 | streamSaver.createWriteStream(folderName + '.zip'), 42 | { signal: opts.abortController?.signal } 43 | ); 44 | }); 45 | } else { 46 | this.finished = this.passThrough.pipeTo( 47 | streamSaver.createWriteStream(folderName + '.zip'), 48 | { signal: opts.abortController?.signal } 49 | ); 50 | } 51 | } 52 | 53 | addFile(name: string, source: ReadableStream): void { 54 | if (this.abortController?.signal.aborted) return; 55 | 56 | this.zip.addFile(name, source); 57 | } 58 | 59 | addFolder(name: string): void { 60 | if (this.abortController?.signal.aborted) return; 61 | 62 | this.zip.addFolder(name); 63 | } 64 | 65 | async close(): Promise { 66 | if (this.abortController?.signal.aborted) return; 67 | 68 | this.zip.end(); 69 | 70 | if (isBrave()) { 71 | console.log('is Brave'); 72 | return fileDownload( 73 | await binaryStreamToBlob(this.passThrough), 74 | `${this.folderName}.zip`, 75 | 'application/zip' 76 | ); 77 | } 78 | 79 | await this.finished; 80 | } 81 | 82 | abort(): void { 83 | this.abortController?.abort(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/InstrumentSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/styles/fonts/InstrumentSans/InstrumentSans-Bold.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/InstrumentSans-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/styles/fonts/InstrumentSans/InstrumentSans-Medium.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/InstrumentSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/styles/fonts/InstrumentSans/InstrumentSans-Regular.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/InstrumentSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/styles/fonts/InstrumentSans/InstrumentSans-SemiBold.woff2 -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/InstrumentSans[wdth,wght].woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/internxt/send-web/833a65279e0095dc4b7ce70e66b80389247f9a9a/src/styles/fonts/InstrumentSans/InstrumentSans[wdth,wght].woff2 -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /src/styles/fonts/InstrumentSans/font.css: -------------------------------------------------------------------------------- 1 | /* Instrument Sans font family */ 2 | 3 | @font-face { 4 | font-family: 'Instrument Sans'; 5 | font-style: normal; 6 | font-display: swap; 7 | src: local('Instrument Sans'), url('./InstrumentSans[wdth\,wght].woff2') format('woff2'); 8 | } 9 | 10 | /* @font-face { 11 | font-family: 'Instrument Sans'; 12 | font-weight: 400; 13 | font-style: normal; 14 | font-display: swap; 15 | src: local('Instrument Sans'), url('./InstrumentSans-Regular.woff2') format('woff2'); 16 | } 17 | 18 | @font-face { 19 | font-family: 'Instrument Sans'; 20 | font-weight: 500; 21 | font-style: normal; 22 | font-display: swap; 23 | src: local('Instrument Sans'), url('./InstrumentSans-Medium.woff2') format('woff2'); 24 | } 25 | 26 | @font-face { 27 | font-family: 'Instrument Sans'; 28 | font-weight: 600; 29 | font-style: normal; 30 | font-display: swap; 31 | src: local('Instrument Sans'), url('./InstrumentSans-SemiBold.woff2') format('woff2'); 32 | } 33 | 34 | @font-face { 35 | font-family: 'Instrument Sans'; 36 | font-weight: 700; 37 | font-style: normal; 38 | font-display: swap; 39 | src: local('Instrument Sans'), url('./InstrumentSans-Bold.woff2') format('woff2'); 40 | } */ 41 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import url("./fonts/InstrumentSans/font.css"); 6 | 7 | .fade-in-animation { 8 | animation-name: FadeIn; 9 | animation-duration: 500ms; 10 | transition-timing-function: ease-in-out; 11 | } 12 | 13 | iframe { 14 | overflow: hidden; 15 | height: 0; 16 | } 17 | 18 | @keyframes FadeIn { 19 | 0% { 20 | opacity: 0; 21 | } 22 | 23 | 100% { 24 | opacity: 1; 25 | } 26 | } 27 | 28 | html, 29 | body { 30 | background: #ffffff; 31 | background-color: #ffffff; 32 | } 33 | 34 | .grecaptcha-badge { 35 | visibility: hidden !important; 36 | } 37 | 38 | .dropdown { 39 | top: -90px; 40 | left: 20px; 41 | } 42 | 43 | .revealY { 44 | will-change: transform; 45 | transform: translateY(150px); 46 | opacity: 0; 47 | transition: 1s cubic-bezier(0.21, 1.23, 1, 0.97); 48 | } 49 | 50 | .revealY.active { 51 | transform: translateY(0); 52 | opacity: 1; 53 | } 54 | 55 | .revealXRight { 56 | will-change: transform; 57 | transform: translateX(-150px); 58 | opacity: 0; 59 | transition: 1s cubic-bezier(0.21, 1.23, 1, 0.97); 60 | } 61 | 62 | .revealXRight.active { 63 | transform: translateX(0); 64 | opacity: 1; 65 | } 66 | 67 | .revealXLeft { 68 | will-change: transform; 69 | transform: translateX(150px); 70 | opacity: 0; 71 | transition: 1s cubic-bezier(0.21, 1.23, 1, 0.97); 72 | } 73 | 74 | .revealXLeft.active { 75 | transform: translateX(0); 76 | opacity: 1; 77 | } 78 | -------------------------------------------------------------------------------- /src/views/NotFoundView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | export default function NotFoundView({ 5 | className = "", 6 | }: { 7 | className?: string; 8 | }) { 9 | const navigate = useNavigate(); 10 | 11 | useEffect(() => { 12 | navigate("/"); 13 | }, []); 14 | 15 | return
; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | // vitest.config.ts 2 | import replace from "@rollup/plugin-replace"; 3 | import react from "@vitejs/plugin-react"; 4 | import path from "path"; 5 | import { defineConfig } from "vitest/config"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | replace({ 11 | preventAssignment: true, 12 | "process.browser": true, 13 | }), 14 | ], 15 | resolve: { 16 | alias: { 17 | // eslint-disable-next-line no-undef 18 | app: path.resolve(__dirname, "./src/app"), 19 | crypto: "crypto-browserify", // Resolve `crypto` to `crypto-browserify` 20 | stream: "stream-browserify", 21 | }, 22 | }, 23 | test: { 24 | environment: "jsdom", 25 | globals: true, 26 | setupFiles: "./src/setupTests.ts", 27 | exclude: ["node_modules", "dist"], 28 | include: [ 29 | "src/**/*.test.{ts,tsx,js,jsx}", 30 | "test/unit/**/*.test.{ts,tsx,js,jsx}", 31 | ], 32 | // coverage: { 33 | // provider: "istanbul", 34 | // reporter: ["text", "lcov"], 35 | // reportsDirectory: "./coverage", 36 | // include: ["src/**/*.{js,ts,jsx,tsx}", "test/unit/**/*.{js,ts,jsx,tsx}"], 37 | // }, 38 | }, 39 | optimizeDeps: { 40 | esbuildOptions: { 41 | // Node.js global to browser globalThis 42 | define: { 43 | global: "globalThis", 44 | }, 45 | }, 46 | }, 47 | }); 48 | --------------------------------------------------------------------------------