├── packages └── send │ ├── e2e │ ├── test.txt │ └── send.spec.ts │ ├── backend │ ├── .dockerignore │ ├── src │ │ ├── @types │ │ │ ├── express-session.d.ts │ │ │ └── express.d.ts │ │ ├── test │ │ │ ├── storage │ │ │ │ ├── data │ │ │ │ │ └── file.txt │ │ │ │ ├── backblaze.test.ts │ │ │ │ └── s3.test.ts │ │ │ ├── testutils.ts │ │ │ ├── routes │ │ │ │ ├── containers.routes.test.ts │ │ │ │ └── containers.test.ts │ │ │ ├── metrics │ │ │ │ └── metrics.test.ts │ │ │ └── testutils.test.ts │ │ ├── types │ │ │ ├── upload.ts │ │ │ └── custom.ts │ │ ├── utils │ │ │ ├── session.ts │ │ │ ├── compatibility.ts │ │ │ ├── logger.ts │ │ │ └── compatibility.test.ts │ │ ├── wsMsgHandler.ts │ │ ├── trpc.ts │ │ ├── swagger.ts │ │ ├── ws │ │ │ ├── login.ts │ │ │ └── setup.ts │ │ ├── origins.ts │ │ ├── metrics │ │ │ ├── index.ts │ │ │ └── posthog.ts │ │ ├── trpc │ │ │ └── context.ts │ │ ├── routes │ │ │ ├── metrics.ts │ │ │ └── download.ts │ │ ├── auth │ │ │ └── jwt.ts │ │ ├── storage │ │ │ └── s3b2.ts │ │ ├── sentry.ts │ │ └── config.ts │ ├── public │ │ └── icons │ │ │ ├── 64.png │ │ │ └── 64-dev.png │ ├── b2 │ │ ├── retention.json │ │ └── rules.json │ ├── prisma │ │ └── migrations │ │ │ ├── 20240814093858_initial_migration │ │ │ └── migration.sql │ │ │ ├── migration_lock.toml │ │ │ ├── 20240924161618_initial │ │ │ └── migration.sql │ │ │ ├── 20241021185730_add_login_attempt_table │ │ │ └── migration.sql │ │ │ └── 20250130173835_add_password_attempt_lock │ │ │ └── migration.sql │ ├── .prettierrc │ ├── tls-dev-proxy │ │ ├── Dockerfile │ │ ├── certs │ │ │ ├── localhost.crt │ │ │ └── localhost.key │ │ └── nginx.conf │ ├── scripts │ │ ├── build.sh │ │ ├── release.sh │ │ ├── environments.sh │ │ └── entry.sh │ ├── .gitignore │ ├── .prettierignore │ ├── Dockerfile │ ├── Dockerfile-dev │ ├── vitest.config.js │ ├── eslint.config.mjs │ ├── tsconfig.json │ └── .env.sample │ ├── frontend │ ├── src │ │ ├── apps │ │ │ ├── chat │ │ │ │ ├── components │ │ │ │ │ ├── NavBar.vue │ │ │ │ │ ├── NewConversation.vue │ │ │ │ │ ├── BurnButton.vue │ │ │ │ │ ├── InvitationList.vue │ │ │ │ │ └── AddPerson.vue │ │ │ │ └── views │ │ │ │ │ ├── FileFolderList.vue │ │ │ │ │ ├── Home.vue │ │ │ │ │ └── Conversations.vue │ │ │ ├── common │ │ │ │ ├── TBIcon.vue │ │ │ │ ├── constants.ts │ │ │ │ ├── DownloadIcon.vue │ │ │ │ ├── CloseButton.vue │ │ │ │ ├── FxaLogin.vue │ │ │ │ ├── LoadingComponent.vue │ │ │ │ ├── errors │ │ │ │ │ └── ErrorGeneric.vue │ │ │ │ ├── NotFoundPage.vue │ │ │ │ ├── TBBanner.vue │ │ │ │ ├── CloseIcon.vue │ │ │ │ ├── ErrorBoundary.vue │ │ │ │ ├── ModalComponent.vue │ │ │ │ ├── ExpandIcon.vue │ │ │ │ ├── modals │ │ │ │ │ ├── ResetModal.vue │ │ │ │ │ └── DownloadModal.vue │ │ │ │ ├── PublicLogin.vue │ │ │ │ ├── download.svg │ │ │ │ ├── FeedbackBox.vue │ │ │ │ ├── mixins │ │ │ │ │ └── metrics.ts │ │ │ │ ├── ShieldIcon.vue │ │ │ │ ├── OpenerIcon.vue │ │ │ │ └── CompatibilityBanner.vue │ │ │ └── send │ │ │ │ ├── elements │ │ │ │ ├── Avatar.vue │ │ │ │ ├── Tag.vue │ │ │ │ ├── TagLabel.vue │ │ │ │ ├── FolderTableRowCell.vue │ │ │ │ ├── ContactCard.vue │ │ │ │ ├── PermissionsDropDown.vue │ │ │ │ ├── LogOutButton.vue │ │ │ │ ├── FileNameForm.vue │ │ │ │ ├── FolderNameForm.vue │ │ │ │ └── BtnComponent.vue │ │ │ │ ├── pages │ │ │ │ ├── LockedPage.vue │ │ │ │ ├── WebPage.vue │ │ │ │ └── SharePage.vue │ │ │ │ ├── extension.js │ │ │ │ ├── management.js │ │ │ │ ├── send.js │ │ │ │ ├── components │ │ │ │ ├── ErrorUploading.vue │ │ │ │ ├── ProgressBarDashboard.vue │ │ │ │ ├── NewFolder.vue │ │ │ │ ├── ProfileView.vue │ │ │ │ ├── SpinnerAnimated.vue │ │ │ │ ├── ProgressBar.vue │ │ │ │ ├── UploadingProgress.vue │ │ │ │ ├── ResetConfirmation.vue │ │ │ │ ├── BreadCrumb.vue │ │ │ │ ├── FolderNavigation.vue │ │ │ │ ├── DownloadConfirmation.vue │ │ │ │ ├── AddTag.vue │ │ │ │ ├── Received.vue │ │ │ │ ├── Sent.vue │ │ │ │ └── ReportContent.vue │ │ │ │ ├── setup.js │ │ │ │ ├── const.js │ │ │ │ ├── stores │ │ │ │ ├── folder-store.types.ts │ │ │ │ ├── config-store.ts │ │ │ │ └── status-store.ts │ │ │ │ ├── views │ │ │ │ ├── ViewShare.vue │ │ │ │ └── HomeView.vue │ │ │ │ ├── SendPage.vue │ │ │ │ └── ExtensionPage.vue │ │ ├── env.d.ts │ │ ├── lib │ │ │ ├── testUtils.ts │ │ │ ├── fxa.ts │ │ │ ├── passphrase.ts │ │ │ ├── messages.ts │ │ │ ├── logger.ts │ │ │ ├── messageSocket.ts │ │ │ ├── storage │ │ │ │ ├── LocalStorage.ts │ │ │ │ └── index.ts │ │ │ ├── permissions.ts │ │ │ ├── const.ts │ │ │ ├── config.ts │ │ │ ├── clientConfig.ts │ │ │ ├── init.ts │ │ │ ├── sentry.ts │ │ │ └── download.ts │ │ ├── test │ │ │ ├── setup │ │ │ │ └── webcrypto.js │ │ │ ├── lib │ │ │ │ ├── helpers.ts │ │ │ │ ├── utils.test.ts │ │ │ │ └── storage.test.ts │ │ │ ├── logger.test.ts │ │ │ └── apps │ │ │ │ └── lockbox │ │ │ │ └── components │ │ │ │ └── FolderVue.test.ts │ │ ├── types │ │ │ └── index.ts │ │ ├── stores │ │ │ ├── user-store.types.ts │ │ │ ├── api-store.ts │ │ │ ├── keychain-store.ts │ │ │ └── metrics.ts │ │ ├── plugins │ │ │ └── posthog.js │ │ └── logger.ts │ ├── favicon.ico │ ├── Dockerfile │ ├── public │ │ ├── icons │ │ │ ├── 64.png │ │ │ └── 64-dev.png │ │ ├── manifest.json │ │ └── api │ │ │ └── schema.json │ ├── .vscode │ │ └── extensions.json │ ├── scripts │ │ ├── entry.sh │ │ ├── release.sh │ │ ├── config.ts │ │ ├── build.sh │ │ ├── submit.sh │ │ ├── set-id.ts │ │ └── bump.ts │ ├── .prettierrc │ ├── postcss.config.js │ ├── tailwind.config.cjs │ ├── .prettierignore │ ├── sharedViteConfig.ts │ ├── .env.sample │ ├── index.extension.html │ ├── index.management.html │ ├── .gitignore │ ├── vitest.config.js │ ├── index.html │ ├── tsconfig.json │ ├── eslint.config.mjs │ ├── vite.config.extension.js │ ├── vite.config.management.js │ ├── README.md │ └── vite.config.js │ ├── data │ └── lockboxstate.json │ ├── pulumi │ ├── requirements.txt │ ├── Pulumi.yaml │ ├── pyproject.toml │ ├── cloudfront-rewrite.js │ ├── ruff.toml │ ├── Pulumi.ci.yaml │ └── Pulumi.prod.yaml │ ├── screenshots │ ├── debug-add-ons.png │ ├── load-temporary.png │ ├── secret-message.png │ ├── choose-manifest-json.png │ ├── configure-extension.png │ ├── convert-attachment.png │ ├── download-hello-txt.png │ ├── extension-test-page.png │ ├── populated-url-input.png │ ├── is-the-backend-running.png │ ├── attachment-link-in-body.png │ └── temporary-extension-loaded.png │ ├── scripts │ ├── clean.sh │ ├── pre-flight.sh │ ├── setup.sh │ ├── local.ts │ └── e2e.sh │ ├── .editorconfig │ ├── .gitignore │ ├── docs │ ├── Encryption.md │ └── README.md │ ├── package.json │ └── compose.yml ├── pnpm-workspace.yaml ├── .npmrc ├── lerna.json ├── package.json ├── README.md ├── .gitignore └── .husky └── pre-commit /packages/send/e2e/test.txt: -------------------------------------------------------------------------------- 1 | bork -------------------------------------------------------------------------------- /packages/send/backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /packages/send/backend/src/@types/express-session.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/storage/data/file.txt: -------------------------------------------------------------------------------- 1 | abcd1234 -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/views/FileFolderList.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/send/frontend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __APP_VERSION__: string; 2 | -------------------------------------------------------------------------------- /packages/send/data/lockboxstate.json: -------------------------------------------------------------------------------- 1 | { 2 | "cookies": [], 3 | "origins": [] 4 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/send" 3 | - "packages/send/backend" 4 | - "packages/send/frontend" 5 | -------------------------------------------------------------------------------- /packages/send/frontend/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/frontend/favicon.ico -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/testUtils.ts: -------------------------------------------------------------------------------- 1 | export function getByTestId(id) { 2 | return `[data-testid="${id}"]`; 3 | } 4 | -------------------------------------------------------------------------------- /packages/send/pulumi/requirements.txt: -------------------------------------------------------------------------------- 1 | tb_pulumi @ git+https://github.com/thunderbird/pulumi.git@v0.0.13 2 | pulumi_cloudflare==5.48.0 3 | -------------------------------------------------------------------------------- /packages/send/backend/public/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/backend/public/icons/64.png -------------------------------------------------------------------------------- /packages/send/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.14.0 2 | 3 | RUN npm install -g pnpm 4 | WORKDIR /app 5 | CMD ["/bin/sh", "./scripts/entry.sh"] -------------------------------------------------------------------------------- /packages/send/frontend/public/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/frontend/public/icons/64.png -------------------------------------------------------------------------------- /packages/send/backend/public/icons/64-dev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/backend/public/icons/64-dev.png -------------------------------------------------------------------------------- /packages/send/screenshots/debug-add-ons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/debug-add-ons.png -------------------------------------------------------------------------------- /packages/send/screenshots/load-temporary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/load-temporary.png -------------------------------------------------------------------------------- /packages/send/screenshots/secret-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/secret-message.png -------------------------------------------------------------------------------- /packages/send/frontend/public/icons/64-dev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/frontend/public/icons/64-dev.png -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/TBIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const PHRASE_SIZE = 6; 2 | export const BASE_URL = import.meta.env.VITE_SEND_CLIENT_URL; 3 | -------------------------------------------------------------------------------- /packages/send/screenshots/choose-manifest-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/choose-manifest-json.png -------------------------------------------------------------------------------- /packages/send/screenshots/configure-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/configure-extension.png -------------------------------------------------------------------------------- /packages/send/screenshots/convert-attachment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/convert-attachment.png -------------------------------------------------------------------------------- /packages/send/screenshots/download-hello-txt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/download-hello-txt.png -------------------------------------------------------------------------------- /packages/send/screenshots/extension-test-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/extension-test-page.png -------------------------------------------------------------------------------- /packages/send/screenshots/populated-url-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/populated-url-input.png -------------------------------------------------------------------------------- /packages/send/backend/b2/retention.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysFromHidingToDeleting": 30, 3 | "daysFromUploadingToHiding": 15, 4 | "fileNamePrefix": "backup/" 5 | } 6 | -------------------------------------------------------------------------------- /packages/send/backend/prisma/migrations/20240814093858_initial_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "uniqueHash" TEXT; 3 | -------------------------------------------------------------------------------- /packages/send/backend/src/types/upload.ts: -------------------------------------------------------------------------------- 1 | export interface Upload { 2 | id: `${string}-${string}-${string}-${string}-${string}`; 3 | ownerId: string; 4 | } 5 | -------------------------------------------------------------------------------- /packages/send/screenshots/is-the-backend-running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/is-the-backend-running.png -------------------------------------------------------------------------------- /packages/send/backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /packages/send/screenshots/attachment-link-in-body.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/attachment-link-in-body.png -------------------------------------------------------------------------------- /packages/send/screenshots/temporary-extension-loaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thunderbird/send-suite/HEAD/packages/send/screenshots/temporary-extension-loaded.png -------------------------------------------------------------------------------- /packages/send/scripts/clean.sh: -------------------------------------------------------------------------------- 1 | cd frontend && rm -rf node_modules && rm -rf dist && rm -rf dist_web && cd .. 2 | cd backend && rm -rf node_modules && rm -rf dist && cd .. -------------------------------------------------------------------------------- /packages/send/backend/tls-dev-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:latest 2 | 3 | COPY nginx.conf /etc/nginx/nginx.conf 4 | COPY certs /etc/ssl/certs 5 | RUN mkdir /var/cache/nginx/client_temp -------------------------------------------------------------------------------- /packages/send/frontend/src/test/setup/webcrypto.js: -------------------------------------------------------------------------------- 1 | import { webcrypto } from 'node:crypto'; 2 | 3 | Object.defineProperty(globalThis, 'crypto', { 4 | value: webcrypto, 5 | }); 6 | -------------------------------------------------------------------------------- /packages/send/frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "Vue.vscode-typescript-vue-plugin", 5 | "vitest.explorer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/send/frontend/scripts/entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo 'installing frontend deps 🤖' 3 | pnpm install --no-frozen-lockfile 4 | 5 | echo 'Starting dev server 🦄' 6 | pnpm run dev 7 | -------------------------------------------------------------------------------- /packages/send/backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /packages/send/backend/scripts/build.sh: -------------------------------------------------------------------------------- 1 | BUILD_PATH=$(pwd)/backend/build 2 | echo $BUILD_PATH 3 | rm -rf $BUILD_PATH 4 | # Build for prod 5 | pnpm --filter send-backend --prod deploy backend/build -------------------------------------------------------------------------------- /packages/send/frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey } from 'vue'; 2 | import dayjs from 'dayjs'; 3 | 4 | export const DayJsKey = Symbol() as InjectionKey; 5 | -------------------------------------------------------------------------------- /packages/send/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "editorconfig": true, 3 | "trailingComma": "es5", 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # This value is required to avoid annoying warnings 2 | inject-workspace-packages=true 3 | # This value is only used when we set overrides in the root package.json 4 | # force-legacy-deploy=true -------------------------------------------------------------------------------- /packages/send/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/DownloadIcon.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /packages/send/backend/prisma/migrations/20240924161618_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Upload" ADD COLUMN "reported" BOOLEAN NOT NULL DEFAULT false, 3 | ADD COLUMN "reportedAt" TIMESTAMP(3); 4 | -------------------------------------------------------------------------------- /packages/send/backend/src/@types/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | flow_values?: any; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/send/backend/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Sentry Config File 3 | .sentryclirc 4 | 5 | # Sentry Config File 6 | .sentryclirc 7 | 8 | # Filesystem files 9 | /data 10 | 11 | /send-fs-local 12 | 13 | /build 14 | /builds -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/Avatar.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/send/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | end_of_line = lf 7 | 8 | [*.{vue,html,css,js,json,yml}] 9 | indent_style = space 10 | indent_size = 2 11 | max_line_length = 120 -------------------------------------------------------------------------------- /packages/send/backend/prisma/migrations/20241021185730_add_login_attempt_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Login" ( 3 | "fxasession" TEXT NOT NULL, 4 | 5 | CONSTRAINT "Login_pkey" PRIMARY KEY ("fxasession") 6 | ); 7 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "independent", 4 | "packages": [ 5 | "packages/send", 6 | "packages/send/backend", 7 | "packages/send/frontend" 8 | ], 9 | "npmClient": "pnpm" 10 | } -------------------------------------------------------------------------------- /packages/send/frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require('@tailwindcss/forms')], 8 | }; 9 | -------------------------------------------------------------------------------- /packages/send/backend/prisma/migrations/20250130173835_add_password_attempt_lock/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "AccessLink" ADD COLUMN "locked" BOOLEAN NOT NULL DEFAULT false, 3 | ADD COLUMN "passwordHash" TEXT, 4 | ADD COLUMN "retryCount" INTEGER NOT NULL DEFAULT 0; 5 | -------------------------------------------------------------------------------- /packages/send/backend/src/utils/session.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto'; 2 | 3 | export const getUniqueHashFromAnonId = (anon_id: string): string => { 4 | const hashedString = createHash('sha256').update(anon_id).digest('hex'); 5 | 6 | return `f'anon-${hashedString}`; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/send/.gitignore: -------------------------------------------------------------------------------- 1 | # Pulumi-related things to ignore 2 | ./tb_pulumi/ 3 | pulumi/tb_pulumi/ 4 | pulumi/*.pyc 5 | pulumi/venv/ 6 | pulumi/__pycache__/ 7 | .venv 8 | venv 9 | 10 | # E2E 11 | /test-results/ 12 | /playwright-report/ 13 | /blob-report/ 14 | /playwright/.cache/ 15 | /data 16 | /frontend-source* 17 | -------------------------------------------------------------------------------- /packages/send/backend/scripts/release.sh: -------------------------------------------------------------------------------- 1 | (pnpm version patch && \ 2 | VERSION=$(node -p "require('./package.json').version") && \ 3 | git checkout -b release/backend-${VERSION//./-} && \ 4 | git add . && \ 5 | git commit -m "chore: bump backend version to ${VERSION}" && \ 6 | git push origin release/backend-${VERSION//./-}) -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/pages/LockedPage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /packages/send/frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules 3 | /dist 4 | 5 | # Version control directories 6 | .git 7 | 8 | # Build outputs 9 | *.min.js 10 | *.min.css 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Environment files 17 | .env 18 | .env.* 19 | 20 | /.pnpm-store/ 21 | .pnpm-lock.yaml 22 | -------------------------------------------------------------------------------- /packages/send/backend/scripts/environments.sh: -------------------------------------------------------------------------------- 1 | pwd 2 | 3 | if [ "$NODE_ENV" == "production" ]; then 4 | echo "Creating .env file for production" 5 | cp .env.sample ./.env.sendbackend 6 | echo "prod" 7 | else 8 | echo "Creating .env file for development" 9 | echo $NODE_ENV 10 | cp .env ./.env.sendbackend 11 | fi 12 | -------------------------------------------------------------------------------- /packages/send/pulumi/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Ref: https://www.pulumi.com/docs/concepts/projects/project-file/ 3 | 4 | name: send-suite 5 | runtime: 6 | name: python 7 | options: 8 | toolchain: pip 9 | virtualenv: venv 10 | description: Lockbox/Send 11 | config: 12 | pulumi:tags: 13 | value: 14 | pulumi:template: aws-python 15 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/Tag.vue: -------------------------------------------------------------------------------- 1 | 8 | 12 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/extension.js: -------------------------------------------------------------------------------- 1 | import { initSentry } from '@/lib/sentry'; 2 | import { createApp } from 'vue'; 3 | import Extension from './ExtensionPage.vue'; 4 | import { mountApp, setupApp } from './setup'; 5 | 6 | const app = createApp(Extension); 7 | 8 | initSentry(app); 9 | 10 | setupApp(app); 11 | mountApp(app, '#extension-page'); 12 | -------------------------------------------------------------------------------- /packages/send/frontend/src/stores/user-store.types.ts: -------------------------------------------------------------------------------- 1 | export type Backup = { 2 | backupContainerKeys: string; 3 | backupKeypair: string; 4 | backupKeystring: string; 5 | backupSalt: string; 6 | }; 7 | 8 | export type UserResponse = { 9 | id: string; 10 | email: string; 11 | tier: string; 12 | createdAt?: Date; 13 | updatedAt?: Date; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/management.js: -------------------------------------------------------------------------------- 1 | import { initSentry } from '@/lib/sentry'; 2 | import { createApp } from 'vue'; 3 | import ManagementPage from './ManagementPage.vue'; 4 | import { mountApp, setupApp } from './setup'; 5 | 6 | const app = createApp(ManagementPage); 7 | 8 | initSentry(app); 9 | setupApp(app); 10 | mountApp(app, '#management-page'); 11 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/CloseButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/FxaLogin.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/send.js: -------------------------------------------------------------------------------- 1 | import { initSentry } from '@/lib/sentry'; 2 | import { createApp } from 'vue'; 3 | import Send from './SendPage.vue'; 4 | import router from './router'; 5 | import { mountApp, setupApp } from './setup'; 6 | 7 | const app = createApp(Send); 8 | 9 | initSentry(app); 10 | app.use(router); 11 | setupApp(app); 12 | mountApp(app, '#app'); 13 | -------------------------------------------------------------------------------- /packages/send/backend/src/wsMsgHandler.ts: -------------------------------------------------------------------------------- 1 | export default function (ws, clients) { 2 | ws.on('message', (msgString) => { 3 | const msg = JSON.parse(msgString); 4 | [...clients.keys()].forEach((key) => { 5 | const client = clients.get(key); 6 | // TODO: figure out why I had to parse and then re-stringify. 7 | client.send(JSON.stringify(msg)); 8 | }); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/send/frontend/scripts/release.sh: -------------------------------------------------------------------------------- 1 | (pnpm version patch && \ 2 | wait && \ 3 | VERSION=$(node -p "require('./package.json').version") && \ 4 | wait && \ 5 | git checkout -b release/frontend-${VERSION//./-} && \ 6 | wait && \ 7 | git add . && \ 8 | wait && \ 9 | git commit -m "chore: bump frontend version to ${VERSION}" && \ 10 | wait && \ 11 | git push origin release/frontend-${VERSION//./-}) -------------------------------------------------------------------------------- /packages/send/backend/src/test/testutils.ts: -------------------------------------------------------------------------------- 1 | export function shouldRunSuite( 2 | config: Record, 3 | suiteName: string 4 | ) { 5 | if (process.env.IS_CI_AUTOMATION) return true; 6 | const canRun = Object.values(config).every((value) => !!value); 7 | if (!canRun) { 8 | console.warn(`env variables are not correctly set to run ${suiteName}`); 9 | } 10 | return canRun; 11 | } 12 | -------------------------------------------------------------------------------- /packages/send/docs/Encryption.md: -------------------------------------------------------------------------------- 1 | The system uses end-to-end encryption, and the keys are never stored in cleartext on the server. 2 | 3 | Every file is encrypted with a unique Content Encryption Key before it is uploaded. 4 | Every folder has its own Key Encryption Key. 5 | To represent the existence of an encrypted file in a folder, I create an `Item`, which has a field for the wrapped key (the Content Encryption Key). 6 | -------------------------------------------------------------------------------- /packages/send/frontend/sharedViteConfig.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { UserConfig } from 'vite'; 4 | 5 | export const packageJson = JSON.parse( 6 | fs.readFileSync(path.resolve(__dirname, './package.json'), 'utf8') 7 | ); 8 | 9 | export const sharedViteConfig: UserConfig = { 10 | define: { 11 | __APP_VERSION__: JSON.stringify(packageJson.version), 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/send/backend/.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules 3 | /dist 4 | 5 | # Version control directories 6 | .git 7 | 8 | # Build outputs 9 | *.min.js 10 | *.min.css 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Environment files 17 | .env 18 | .env.* 19 | 20 | # Storybook directory 21 | /.storybook 22 | 23 | # Coverage directory 24 | /coverage 25 | 26 | /.pnpm-store/ 27 | .pnpm-lock.yaml -------------------------------------------------------------------------------- /packages/send/backend/src/trpc.ts: -------------------------------------------------------------------------------- 1 | // @filename: trpc.ts 2 | import { initTRPC } from '@trpc/server'; 3 | import { createContext } from './trpc/context'; 4 | 5 | export type Context = Awaited>; 6 | 7 | const t = initTRPC.context().create(); 8 | 9 | export const router = t.router; 10 | export const publicProcedure = t.procedure; 11 | export const mergeRouters = t.mergeRouters; 12 | -------------------------------------------------------------------------------- /packages/send/frontend/.env.sample: -------------------------------------------------------------------------------- 1 | VUE_BASE_URL=http://localhost:5173 2 | VITE_SEND_SERVER_URL=https://localhost:8088 3 | VITE_SEND_CLIENT_URL=http://localhost:5173 4 | 5 | # Sentry 6 | VITE_SENTRY_AUTH_TOKEN= 7 | VITE_SENTRY_DSN=https://af0e7594fd7dedb0d5c59ec7ecf169b5@o4505428107853824.ingest.us.sentry.io/4507567067758592 8 | 9 | # Posthog 10 | VITE_POSTHOG_PROJECT_KEY= 11 | VITE_POSTHOG_HOST= 12 | 13 | VITE_ALLOW_PUBLIC_LOGIN=false -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/TagLabel.vue: -------------------------------------------------------------------------------- 1 | 7 | 17 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/pages/WebPage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/fxa.ts: -------------------------------------------------------------------------------- 1 | import { formatLoginURL } from '@/lib/helpers'; 2 | import useApiStore from '@/stores/api-store'; 3 | 4 | export async function mozAcctLogin() { 5 | const { api } = useApiStore(); 6 | const resp = await api.call<{ url: string }>(`lockbox/fxa/login`); 7 | if (!resp.url) { 8 | console.error(`Couldn't get a mozilla auth url`); 9 | } 10 | window.open(formatLoginURL(resp.url)); 11 | } 12 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/LoadingComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/views/Home.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /packages/send/scripts/pre-flight.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if Bun is installed 4 | if command -v bun &> /dev/null 5 | then 6 | echo "✅ Bun is installed." 7 | # Run your post-install script here 8 | bun run scripts/envs.ts 9 | else 10 | echo "Bun is not installed. Please check docs for installation details." 11 | exit 1 12 | fi 13 | 14 | # Make sure prisma types are available for development 15 | cd backend && pnpm db:generate -------------------------------------------------------------------------------- /packages/send/backend/src/swagger.ts: -------------------------------------------------------------------------------- 1 | import swaggerJSDoc from 'swagger-jsdoc'; 2 | import { VERSION } from './config'; 3 | 4 | export const openapiSpecification = swaggerJSDoc({ 5 | swaggerDefinition: { 6 | openapi: '3.0.0', 7 | info: { 8 | title: 'Send API', 9 | version: VERSION, 10 | description: 'API thunderbird send', 11 | }, 12 | }, 13 | apis: ['./src/routes/*.ts', './src/index.ts', './src/trpc/*.ts'], 14 | }); 15 | -------------------------------------------------------------------------------- /packages/send/frontend/index.extension.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Upload with Thunderbird Send 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/send/frontend/index.management.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Thunderbird Send Settings 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/send/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.14.0 2 | 3 | RUN apt update -y && apt upgrade -y 4 | 5 | WORKDIR /app 6 | ADD package.json \ 7 | pnpm-lock.yaml \ 8 | tsconfig.json \ 9 | ./ 10 | ADD prisma ./prisma 11 | ADD public ./public 12 | ADD scripts ./scripts 13 | ADD src ./src 14 | 15 | RUN npm install -g pnpm && \ 16 | pnpm install --no-frozen-lockfile && \ 17 | chown -R node:node /app 18 | 19 | CMD ["/bin/sh", "/app/scripts/entry.sh"] -------------------------------------------------------------------------------- /packages/send/backend/src/utils/compatibility.ts: -------------------------------------------------------------------------------- 1 | export const checkCompatibility = (versionA: string, versionB: string) => { 2 | const [majorA, minorA] = versionA.split('.').map(Number); 3 | const [majorB, minorB] = versionB.split('.').map(Number); 4 | 5 | if (majorA !== majorB) { 6 | return 'FORCE_UPDATE'; 7 | } 8 | 9 | if (majorA === majorB && minorA !== minorB) { 10 | return 'PROMPT_UPDATE'; 11 | } 12 | return 'COMPATIBLE'; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/send/backend/src/ws/login.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@/utils/logger'; 2 | import { EventEmitter } from 'websocket'; 3 | 4 | export const loginEmitter = new EventEmitter(); 5 | 6 | loginEmitter.on('login_complete', () => 7 | logger.log('Login complete event received') 8 | ); 9 | loginEmitter.on('login_attempt', () => 10 | logger.log('Login attempt event received') 11 | ); 12 | loginEmitter.on('login_url_requested', () => logger.log('Login url requested')); 13 | -------------------------------------------------------------------------------- /packages/send/frontend/src/stores/api-store.ts: -------------------------------------------------------------------------------- 1 | import { useConfigStore } from '@/apps/send/stores/config-store'; 2 | import { ApiConnection } from '@/lib/api'; 3 | import { defineStore } from 'pinia'; 4 | 5 | const useApiStore = defineStore('api', () => { 6 | const configurationStore = useConfigStore(); 7 | const url = configurationStore.serverUrl; 8 | const api = new ApiConnection(url); 9 | return { 10 | api, 11 | }; 12 | }); 13 | 14 | export default useApiStore; 15 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/errors/ErrorGeneric.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /packages/send/frontend/src/test/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ProgressTracker } from '@/apps/send/stores/status-store'; 2 | import { vi } from 'vitest'; 3 | 4 | const progressTracker = vi.fn(); 5 | export const mockProgressTracker = { 6 | total: 0, 7 | progressed: 0, 8 | percentage: 0, 9 | error: '', 10 | text: '', 11 | initialize: progressTracker, 12 | setProgress: progressTracker, 13 | setUploadSize: progressTracker, 14 | setText: progressTracker, 15 | } as ProgressTracker; 16 | -------------------------------------------------------------------------------- /packages/send/scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Warn the user this will overwrite their .env files 3 | # If they press Y continue 4 | echo "This script will overwrite your .env files. Press Y then Enter to continue." 5 | read -r response 6 | if [ "$response" != "Y" ]; then 7 | echo "Exiting..." 8 | exit 1 9 | fi 10 | 11 | echo "Copying .env files..." 12 | 13 | # Copy env files 14 | cd frontend 15 | cp .env.sample .env 16 | cd ../backend 17 | cp .env.sample .env 18 | cd .. 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/send/frontend/src/plugins/posthog.js: -------------------------------------------------------------------------------- 1 | //./plugins/posthog.js 2 | 3 | import posthog from 'posthog-js'; 4 | 5 | export default { 6 | install(app) { 7 | app.config.globalProperties.$posthog = posthog.init( 8 | import.meta.env.VITE_POSTHOG_PROJECT_KEY, 9 | { 10 | api_host: import.meta.env.VITE_POSTHOG_HOST, 11 | persistence: 'memory', 12 | } 13 | ); 14 | posthog.register({ 15 | service: 'send', 16 | }); 17 | }, 18 | rest: posthog, 19 | }; 20 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/NotFoundPage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/TBBanner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/passphrase.ts: -------------------------------------------------------------------------------- 1 | import * as bip39 from '@scure/bip39'; 2 | import { wordlist } from '@scure/bip39/wordlists/english'; 3 | 4 | export const generatePassphrase = (phraseSize: number): string[] => { 5 | // Generate a mnemonic phrase with 128 bits of entropy (12 words) 6 | const mnemonicPhrase = bip39.generateMnemonic(wordlist, 128); 7 | const words = mnemonicPhrase.split(' '); 8 | 9 | // Return n words determined by the phraseSize parameter 10 | return words.slice(0, phraseSize); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/send/backend/b2/rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "corsRuleName": "downloadFromAnyOrigin", 4 | "allowedOrigins": ["*"], 5 | "allowedHeaders": ["*"], 6 | "allowedOperations": [ 7 | "s3_put", 8 | "s3_get", 9 | "s3_delete", 10 | "s3_head", 11 | "s3_post", 12 | "b2_download_file_by_id", 13 | "b2_download_file_by_name", 14 | "b2_upload_file", 15 | "b2_upload_part" 16 | ], 17 | "exposeHeaders": ["x-bz-content-sha1"], 18 | "maxAgeSeconds": 3600 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /packages/send/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Sentry Config File 27 | .env.sentry-build-plugin 28 | 29 | # Sentry Config File 30 | .env.sentry-build-plugin 31 | 32 | !vite.config.js.timestamp-*.mjs -------------------------------------------------------------------------------- /packages/send/backend/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | FROM node:22.14.0 2 | 3 | RUN apt update -y && apt upgrade -y 4 | 5 | WORKDIR /app 6 | ADD package.json \ 7 | # We don't copy the lockfile during development 8 | # because we resolve our dependencies at the root 9 | # pnpm-lock.yaml \ 10 | tsconfig.json \ 11 | ./ 12 | ADD prisma ./prisma 13 | ADD public ./public 14 | ADD scripts ./scripts 15 | ADD src ./src 16 | 17 | RUN npm install -g pnpm && \ 18 | pnpm install --no-frozen-lockfile && \ 19 | chown -R node:node /app 20 | 21 | CMD ["/bin/sh", "/app/scripts/entry.sh"] -------------------------------------------------------------------------------- /packages/send/pulumi/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "send-suite-pulumi" 3 | version = "0.0.2" 4 | description = "Pulumi-built infrastructure for send-suite" 5 | requires-python = ">=3.12" 6 | dynamic = ["dependencies"] 7 | 8 | [project.urls] 9 | repository = "https://github.com/thunderbird/send-suite.git" 10 | issues = "https://github.com/thunderbird/send-suite/issues?q=is%3Aopen+is%3Aissue+label%3Apulumi" 11 | 12 | [tool.setuptools.dynamic] 13 | dependencies = { file = ["requirements.txt"] } 14 | 15 | [project.optional-dependencies] 16 | dev = [ 17 | "ruff" 18 | ] -------------------------------------------------------------------------------- /packages/send/frontend/scripts/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | export const ID_FOR_PROD = `"id": "tb-send@thunderbird.net"`; 5 | export const ID_FOR_STAGE = ` "id": "send@thunderbird.net"`; 6 | 7 | export const NAME_FOR_PROD = `"name": "Thunderbird Send"`; 8 | export const NAME_FOR_STAGE = `"name": "Thunderbird Send [STAGE]"`; 9 | 10 | export const PACKAGE_NAME = { 11 | production: 'tb-send', 12 | stage: 'send', 13 | }; 14 | 15 | export const getIsEnvProd = () => { 16 | return process.env.BASE_URL.includes('https://send.tb.pro'); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/send/frontend/vitest.config.js: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue'; 2 | import path from 'path'; 3 | import { defineConfig } from 'vite'; 4 | import viteConfig from './vite.config'; 5 | 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | viteConfig, 9 | resolve: { 10 | alias: { 11 | '@': path.resolve(__dirname, 'src'), 12 | }, 13 | }, 14 | test: { 15 | include: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 16 | environment: 'happy-dom', 17 | setupFiles: ['./src/test/setup/webcrypto.js'], 18 | globals: true, 19 | mockReset: false, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/FolderTableRowCell.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/messages.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This file contains all the messages that are displayed to the user. 3 | Whenever we switch to something like i18n, this file will be the one to change. 4 | */ 5 | 6 | import { MAX_FILE_SIZE_HUMAN_READABLE } from './const'; 7 | 8 | export const CLIENT_MESSAGES = { 9 | SHOULD_LOG_IN: `You need to log into your mozilla account. Make sure you're in the allow list for alpha access.`, 10 | FILE_TOO_BIG: `Your file size is not supported, please try with files smaller than ${MAX_FILE_SIZE_HUMAN_READABLE}`, 11 | UPLOAD_FAILED: `Upload failed. Please try again.`, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/send/backend/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import path from 'path'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | resolve: { 7 | alias: { 8 | '@': path.resolve(__dirname, 'src'), 9 | }, 10 | }, 11 | test: { 12 | // this is a temporary config to use vite on routes tests 13 | include: ['**/**/*.test.{js,ts}'], 14 | exclude: ['**/build/**', '**/node_modules/**'], 15 | environment: 'node', 16 | setupFiles: ['dotenv/config'], 17 | env: { 18 | ...config({ path: './env' }).parsed, 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/ErrorUploading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/pages/SharePage.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /packages/send/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | Thunderbird Send 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monorepo", 3 | "version": "0.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "license": "ISC", 7 | "author": "", 8 | "main": "index.js", 9 | "scripts": { 10 | "prepare": "husky", 11 | "sort-package-json": "sort-package-json" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^22.14.0", 15 | "husky": "^9.1.7", 16 | "lerna": "^8.2.1", 17 | "sort-package-json": "^2.15.1", 18 | "typescript": "^5.8.3", 19 | "vitest": "^3.1.2" 20 | }, 21 | "engines": { 22 | "bun": "1.1.13", 23 | "node": ">=22.11.0", 24 | "pnpm": ">=10.6.4" 25 | }, 26 | "volta": { 27 | "node": "22.14.0" 28 | } 29 | } -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | // utils/console-wrapper.js 2 | const version = __APP_VERSION__; 3 | 4 | const originalConsole = { ...console }; 5 | 6 | const wrapConsoleMethod = (method: string) => { 7 | return function (...args) { 8 | originalConsole[method](`[${version}]`, ...args); 9 | }; 10 | }; 11 | 12 | // Wrap common console methods 13 | console.log = wrapConsoleMethod('log'); 14 | console.info = wrapConsoleMethod('info'); 15 | console.warn = wrapConsoleMethod('warn'); 16 | console.warn = wrapConsoleMethod('table'); 17 | console.error = wrapConsoleMethod('error'); 18 | console.debug = wrapConsoleMethod('debug'); 19 | console.trace = wrapConsoleMethod('trace'); 20 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/ProgressBarDashboard.vue: -------------------------------------------------------------------------------- 1 | 6 | 13 | 14 | 29 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/ContactCard.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 24 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/PermissionsDropDown.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/LogOutButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | 20 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⛔️[DEPRECATED] Thunderbird Send Moved to -> [NEW REPO](https://github.com/thunderbird/tbpro-add-on/) 2 | 3 | Thunderbird Send is an end-to-end encrypted file sharing solution, allowing you to safely encrypt, password-protect and send files through our [website](https://send.tb.pro/) or as an [add-on](https://addons.thunderbird.net/en-US/thunderbird/addon/tb_send/?src=search) for your Thunderbird Desktop application. 4 | 5 | Currently, we are in a closed alpha state. Meanwhile, please join our [waitlist](https://tb.pro/) to try it out during our beta period, or feel free to follow the guide below to run a local or self-hosted version for yourself! 6 | 7 | ## Getting started 8 | 9 | We migrate send to [our new repo](https://github.com/thunderbird/tbpro-add-on/) 10 | 11 | -------------------------------------------------------------------------------- /packages/send/backend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import eslintConfigPrettier from 'eslint-config-prettier'; 3 | import globals from 'globals'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default [ 7 | { 8 | languageOptions: { 9 | globals: { 10 | ...globals.browser, // Existing browser globals 11 | __dirname: 'readonly', // Setting __dirname as a global variable 12 | __filename: 'readonly', // Commonly used alongside __dirname 13 | process: 'readonly', // Another common Node.js global 14 | require: 'readonly', // If using CommonJS modules 15 | }, 16 | }, 17 | }, 18 | pluginJs.configs.recommended, 19 | ...tseslint.configs.recommended, 20 | eslintConfigPrettier, 21 | ]; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | seed.ts 2 | .pnpm-store 3 | 4 | 5 | # Do not track .env files and variants 6 | .*env* 7 | # Only track .env.sample 8 | !.env.sample 9 | 10 | dev.db* 11 | shell.nix* 12 | node_modules 13 | dist 14 | dist-ssr 15 | dist-extension 16 | dist-web 17 | *.xpi 18 | *.local 19 | sessions 20 | .pnpm-store 21 | 22 | # Logs 23 | logs 24 | *.log 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | pnpm-debug.log* 29 | lerna-debug.log* 30 | 31 | 32 | # Editor directories and files 33 | .vscode/* 34 | !.vscode/extensions.json 35 | .idea 36 | .DS_Store 37 | *.suo 38 | *.ntvs* 39 | *.njsproj 40 | *.sln 41 | *.sw? 42 | 43 | # Monorepo 44 | /builds 45 | .nx/cache 46 | # This helps the cache to build faster, we'll disable it until we can test it properly on CI 47 | .nx/workspace-data -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/ErrorBoundary.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 32 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/setup.js: -------------------------------------------------------------------------------- 1 | import '@/lib/logger'; 2 | import posthogPlugin from '@/plugins/posthog'; 3 | import { VueQueryPlugin } from '@tanstack/vue-query'; 4 | import '@thunderbirdops/services-ui/style.css'; 5 | import FloatingVue from 'floating-vue'; 6 | import 'floating-vue/dist/style.css'; 7 | import { createPinia } from 'pinia'; 8 | import { createVfm } from 'vue-final-modal'; 9 | import 'vue-final-modal/style.css'; 10 | import './style.css'; 11 | 12 | export function setupApp(app) { 13 | const pinia = createPinia(); 14 | app.use(VueQueryPlugin); 15 | app.use(pinia); 16 | app.use(FloatingVue); 17 | app.use(posthogPlugin); 18 | } 19 | export function mountApp(app, nodeName) { 20 | const vfm = createVfm(); 21 | app.use(vfm).mount(nodeName); 22 | } 23 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/ModalComponent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | 16 | 38 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/messageSocket.ts: -------------------------------------------------------------------------------- 1 | import { connectToWebSocketServer } from '@/lib/utils'; 2 | 3 | export async function createMessageSocket( 4 | endpoint: string 5 | ): Promise { 6 | const connection = await connectToWebSocketServer(endpoint); 7 | 8 | connection.onclose = function () { 9 | // Uncomment this when you start debugging the disconnection issues. 10 | // console.log( 11 | // 'Socket is closed. Reconnect will be attempted in 1 second.', 12 | // e.reason 13 | // ); 14 | setTimeout(function () { 15 | createMessageSocket(endpoint); 16 | }, 1000); 17 | }; 18 | 19 | connection.onerror = function (err) { 20 | console.error('Socket encountered error: ', err, 'Closing socket'); 21 | connection.close(); 22 | }; 23 | 24 | return connection; 25 | } 26 | -------------------------------------------------------------------------------- /packages/send/frontend/src/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // We should type this propery to be the same type as console.log/error 3 | type Logger = unknown; 4 | 5 | export const loggerPrefix = { 6 | info: 'LOGGER INFO', 7 | error: 'LOGGER ERROR', 8 | warn: 'LOGGER WARNING', 9 | }; 10 | 11 | const info = (message: Logger, ...optionalParams: any[]) => { 12 | console.log(`${loggerPrefix.info} ${message}`, ...optionalParams); 13 | }; 14 | 15 | const error = (message: Logger, ...optionalParams: any[]) => { 16 | console.error(`${loggerPrefix.error} ${message}`, ...optionalParams); 17 | }; 18 | 19 | const warn = (message: Logger, ...optionalParams: any[]) => { 20 | console.warn(`${loggerPrefix.warn} ${message}`, ...optionalParams); 21 | }; 22 | 23 | export default { info, error, warn }; 24 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/ExpandIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/NewFolder.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/storage/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { StorageAdapter } from '.'; 2 | 3 | export default class LocalStorageAdapter implements StorageAdapter { 4 | constructor() {} 5 | 6 | keys() { 7 | const keys = []; 8 | for (let i = 0; i < localStorage.length; i++) { 9 | keys.push(localStorage.key(i)); 10 | } 11 | return keys; 12 | } 13 | 14 | get(key: string): any { 15 | const val = localStorage.getItem(key); 16 | if (!val) { 17 | return null; 18 | } 19 | return JSON.parse(val); 20 | } 21 | 22 | set(key: string, val: any) { 23 | const value = JSON.stringify(val); 24 | localStorage.setItem(key, value); 25 | } 26 | 27 | remove(id: string): void { 28 | localStorage.removeItem(id); 29 | } 30 | 31 | clear(): void { 32 | console.log(`clearing localStorage`); 33 | localStorage.clear(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/permissions.ts: -------------------------------------------------------------------------------- 1 | // TODO: find a way to dynamically sync with backend/src/types/custom.ts 2 | 3 | const NONE = 0; 4 | const READ = 1 << 1; 5 | const WRITE = 1 << 2; 6 | const SHARE = 1 << 3; 7 | const ADMIN = 1 << 4; 8 | 9 | export const PermissionsMap = { 10 | NONE, 11 | READ, 12 | WRITE, 13 | SHARE, 14 | ADMIN, 15 | }; 16 | 17 | // translation 18 | export const PermissionsDescriptions = { 19 | NONE: 'No Access', 20 | READ: 'Can view', 21 | WRITE: 'Can edit', 22 | SHARE: 'Can share', 23 | ADMIN: 'Admin', 24 | }; 25 | 26 | // Look up a description, given a numeric Permission 27 | export const PermissionsTable = { 28 | [NONE]: PermissionsDescriptions['NONE'], 29 | [READ]: PermissionsDescriptions['READ'], 30 | [WRITE]: PermissionsDescriptions['WRITE'], 31 | [SHARE]: PermissionsDescriptions['SHARE'], 32 | [ADMIN]: PermissionsDescriptions['ADMIN'], 33 | }; 34 | -------------------------------------------------------------------------------- /packages/send/backend/src/origins.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { getAllowedOrigins } from './auth/client'; 3 | import cors from 'cors'; 4 | 5 | const allowedOrigins = getAllowedOrigins(); 6 | 7 | export const originsHandler = ( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction 11 | ) => { 12 | const origin = req.headers.origin; 13 | 14 | // Check if it's a Thunderbird extension origin 15 | if (origin && origin.startsWith('moz-extension://')) { 16 | allowedOrigins.push(origin); 17 | } 18 | 19 | // Allow any matching origin 20 | cors({ 21 | origin: (origin, callback) => { 22 | if (!origin || allowedOrigins.indexOf(origin) !== -1) { 23 | callback(null, true); 24 | } else { 25 | callback(new Error('Origin not allowed by CORS')); 26 | } 27 | }, 28 | credentials: true, 29 | })(req, res, next); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/const.ts: -------------------------------------------------------------------------------- 1 | import prettyBytes from 'pretty-bytes'; 2 | 3 | export const CONTAINER_TYPE = { 4 | CONVERSATION: 'CONVERSATION', 5 | FOLDER: 'FOLDER', 6 | }; 7 | 8 | export const ITEM_TYPE = { 9 | MESSAGE: 'MESSAGE', 10 | FILE: 'FILE', 11 | }; 12 | 13 | export const EXTENSION_READY = 'EXTENSION_READY'; 14 | export const SHARE_COMPLETE = 'SHARE_COMPLETE'; 15 | export const SHARE_ABORTED = 'SHARE_ABORTED'; 16 | 17 | export const FILE_SELECTED = 'FILE_SELECTED'; 18 | export const SELECTION_COMPLETE = 'SELECTION_COMPLETE'; 19 | 20 | const ONE_KB_IN_BYTES = 1024; 21 | const ONE_MB_IN_BYTES = ONE_KB_IN_BYTES * 1024; 22 | const ONE_GB_IN_BYTES = ONE_MB_IN_BYTES * 1024; 23 | 24 | export const MAX_FILE_SIZE = ONE_GB_IN_BYTES * 5; 25 | export const DAYS_TO_EXPIRY = 15; 26 | export const MAX_FILE_SIZE_HUMAN_READABLE = prettyBytes(MAX_FILE_SIZE); 27 | 28 | export const MAX_ACCESS_LINK_RETRIES = 5; 29 | -------------------------------------------------------------------------------- /packages/send/pulumi/cloudfront-rewrite.js: -------------------------------------------------------------------------------- 1 | async function handler(event) { 2 | const request = event.request; 3 | const apiPath = "/api"; 4 | const ignorePaths = ['/lockbox/fxa', '/assets', '/sitemap.txt']; 5 | const pathCheckFn = (path) => request.uri.startsWith(path); 6 | 7 | // If our api path is the first thing that's found in the uri then remove it from the uri. 8 | if (request.uri.indexOf(apiPath) === 0) { 9 | request.uri = request.uri.replace(apiPath, ""); 10 | } else if (!ignorePaths.some(pathCheckFn)) { 11 | // If we're not in one of the ignorePaths then force them to /index.html 12 | request.uri = '/index.html'; 13 | } 14 | 15 | // If empty, then add a slash! 16 | // Required by AWS, see https://github.com/thunderbird/appointment/pull/510/ 17 | if (request.uri === '') { 18 | request.uri = '/'; 19 | } 20 | 21 | // else carry on like normal. 22 | return request; 23 | } 24 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/ProfileView.vue: -------------------------------------------------------------------------------- 1 | 9 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /packages/send/backend/scripts/entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Create zip for submission 4 | if [ "$IS_CI_AUTOMATION" != "yes" ]; then 5 | echo 'Skipping lockfile install on CI' 6 | else 7 | # *IS* CI automation 8 | echo 'installing backend deps 🤖' 9 | pnpm install --no-frozen-lockfile 10 | fi 11 | 12 | # Check if environment NODE_ENV has been set to production 13 | if [ "$NODE_ENV" = "production" ]; then 14 | echo 'Starting with NODE_ENV on production 🐧' 15 | fi 16 | 17 | echo 'Applying prisma migrations...' 18 | pnpm db:update 19 | 20 | echo 'Generating prisma client...' 21 | pnpm db:generate 22 | 23 | # Check if environment NODE_ENV has been set to production 24 | if [ "$NODE_ENV" = "production" ]; then 25 | echo 'Starting prod server 🚀' 26 | pnpm start 27 | else 28 | echo 'Starting dev server with debugger 🚀' 29 | echo 'Starting db browser on http://localhost:5555 🔎' 30 | pnpm debug 31 | fi 32 | -------------------------------------------------------------------------------- /packages/send/backend/src/metrics/index.ts: -------------------------------------------------------------------------------- 1 | import { getEnvironmentName } from '@/config'; 2 | import { extended_client } from './posthog'; 3 | 4 | const client = new extended_client(process.env.POSTHOG_API_KEY || 'test', { 5 | host: process.env.POSTHOG_HOST || 'test', 6 | persistence: 'memory', 7 | }); 8 | 9 | client.on('error', (error) => { 10 | console.error('Error in PostHog', error); 11 | }); 12 | 13 | export const useMetrics = () => { 14 | const isProd = getEnvironmentName() === 'prod'; 15 | const isMissingKeys = 16 | !process.env.POSTHOG_API_KEY || !process.env.POSTHOG_HOST; 17 | 18 | if (isMissingKeys) { 19 | console.warn('POSTHOG keys not set'); 20 | } 21 | 22 | if (isProd && isMissingKeys) { 23 | console.error( 24 | `POSTHOG keys not correctly set, we got POSTHOG_API_KEY: ${process.env.POSTHOG_API_KEY} and POSTHOG_HOST: ${process.env.POSTHOG_HOST}` 25 | ); 26 | } 27 | return client; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/modals/ResetModal.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/SpinnerAnimated.vue: -------------------------------------------------------------------------------- 1 | 2 | 26 | 27 | 33 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/modals/DownloadModal.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/PublicLogin.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/const.js: -------------------------------------------------------------------------------- 1 | export const TagColors = { 2 | red: '!stroke-red-500 fill-red-500/20', 3 | orange: '!stroke-orange-500 fill-orange-500/20', 4 | green: '!stroke-green-500 fill-green-500/20', 5 | blue: '!stroke-blue-500 fill-blue-500/20', 6 | fuchsia: '!stroke-fuchsia-500 fill-fuchsia-500/20', 7 | teal: '!stroke-teal-500 fill-teal-500/20', 8 | pink: '!stroke-pink-500 fill-pink-500/20', 9 | }; 10 | export const TagLabelColors = { 11 | red: '!bg-red-500', 12 | orange: '!bg-orange-500', 13 | green: '!bg-green-500', 14 | blue: '!bg-blue-500', 15 | fuchsia: '!bg-fuchsia-500', 16 | teal: '!bg-teal-500', 17 | pink: '!bg-pink-500', 18 | }; 19 | 20 | /** 21 | * Enum for Initialization codes. Non-zero values indicate an error. 22 | * @readonly 23 | * @enum {number} 24 | */ 25 | export const INIT_ERRORS = { 26 | NONE: 0, 27 | NO_USER: 1, 28 | NO_KEYCHAIN: 2, 29 | COULD_NOT_CREATE_DEFAULT_FOLDER: 3, 30 | }; 31 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/components/NewConversation.vue: -------------------------------------------------------------------------------- 1 | 25 | 32 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/components/BurnButton.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 39 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 25 | 26 | 41 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/UploadingProgress.vue: -------------------------------------------------------------------------------- 1 | 2 | 34 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/ResetConfirmation.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | 36 | 41 | -------------------------------------------------------------------------------- /packages/send/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "esModuleInterop": true, 21 | 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | }, 25 | "inlineSources": true, 26 | 27 | // Set `sourceRoot` to "/" to strip the build path prefix 28 | // from generated source code references. 29 | // This improves issue grouping in Sentry. 30 | "sourceRoot": "/" 31 | }, 32 | "exclude": ["node_modules", "dist", "build"] 33 | } 34 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/stores/folder-store.types.ts: -------------------------------------------------------------------------------- 1 | import { UserResponse } from '@/stores/user-store.types'; 2 | import { Container, Item } from '@/types'; 3 | import { FolderStore as FS } from './folder-store'; 4 | 5 | export type ContainerResponse = Container; 6 | 7 | export type FolderResponse = ContainerResponse; 8 | 9 | export type Folder = FolderResponse; 10 | 11 | export type ItemResponse = Item; 12 | 13 | export type UploadResponse = { 14 | id?: string; 15 | size?: number; 16 | ownerId?: number; 17 | type?: string; 18 | createdAt?: Date; 19 | owner?: { 20 | email: string; 21 | }; 22 | daysToExpiry?: number; 23 | expired?: boolean; 24 | reportedAt?: Date; 25 | reported?: boolean; 26 | }; 27 | 28 | export type Upload = UploadResponse; 29 | 30 | export type ShareResponse = { 31 | id?: number; 32 | containerId?: string; 33 | container?: ContainerResponse; 34 | senderId: string; 35 | sender?: UserResponse; 36 | }; 37 | 38 | export type FolderStore = FS; 39 | 40 | export { Container, Item }; 41 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/download.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/send/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "version": "2.0.1", 4 | "author": "Thunderbird Team", 5 | "name": "Thunderbird Send", 6 | "description": "Thunderbird and friends", 7 | "browser_specific_settings": { 8 | "gecko": { 9 | "id": "send@thunderbird.net", 10 | "strict_min_version": "128.0" 11 | } 12 | }, 13 | "background": { 14 | "scripts": ["background.js"] 15 | }, 16 | "options_ui": { 17 | "page": "index.management.html", 18 | "open_in_tab": true, 19 | "browser_style": true 20 | }, 21 | "cloud_file": { 22 | "name": "Thunderbird Send", 23 | "management_url": "index.management.html", 24 | "browser_style": true, 25 | "reuse_uploads": false 26 | }, 27 | "icons": { 28 | "64": "icons/64.png" 29 | }, 30 | "permissions": [ 31 | "storage", 32 | "compose", 33 | "notifications", 34 | "webRequest", 35 | "webRequestBlocking", 36 | "messagesRead", 37 | "sensitiveDataUpload", 38 | "https://*.backblazeb2.com/*" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/views/ViewShare.vue: -------------------------------------------------------------------------------- 1 | 25 | 31 | -------------------------------------------------------------------------------- /packages/send/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2022", 4 | "moduleResolution": "Node", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | "esModuleInterop": true, 22 | "allowJs": true, 23 | "paths": { 24 | "@/*": ["./src/*"], 25 | "server/*": ["../backend/src/*"], 26 | "prisma/*": ["../backend/node_modules/@prisma/*"] 27 | }, 28 | "types": [ 29 | "vite/client", 30 | "vitest/globals", 31 | "@types/firefox-webext-browser", 32 | "@trpc/server" 33 | ] 34 | }, 35 | "exclude": ["node_modules", "dist", "build"] 36 | } 37 | -------------------------------------------------------------------------------- /packages/send/backend/src/metrics/posthog.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from 'posthog-node'; 2 | 3 | // These types are copied from the posthog-node package, we should be careful to update it whenever the package is updated 4 | interface IdentifyMessage { 5 | distinctId: string; 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | properties?: Record; 8 | disableGeoip?: boolean; 9 | } 10 | interface EventMessage extends IdentifyMessage { 11 | event: string; 12 | groups?: Record; 13 | sendFeatureFlags?: boolean; 14 | timestamp?: Date; 15 | uuid?: string; 16 | } 17 | 18 | export class extended_client extends PostHog { 19 | capture({ 20 | distinctId, 21 | event, 22 | properties, 23 | groups, 24 | sendFeatureFlags, 25 | timestamp, 26 | disableGeoip, 27 | uuid, 28 | }: EventMessage): void { 29 | super.capture({ 30 | distinctId, 31 | event, 32 | properties: { ...properties, service: 'send' }, 33 | groups, 34 | sendFeatureFlags, 35 | timestamp, 36 | disableGeoip, 37 | uuid, 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/FeedbackBox.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | 25 | 43 | -------------------------------------------------------------------------------- /packages/send/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "send-suite", 3 | "version": "2.0.1", 4 | "description": "", 5 | "keywords": [], 6 | "license": "ISC", 7 | "author": "", 8 | "main": "index.js", 9 | "scripts": { 10 | "build-image:backend": "./backend/scripts/build.sh", 11 | "clean": "./scripts/clean.sh", 12 | "compare_envs": "bun run scripts/envs.ts", 13 | "predev": "./scripts/pre-flight.sh", 14 | "dev": "docker compose up --build --force-recreate -d && docker compose logs -f", 15 | "dev:detach": "docker compose up -d --build", 16 | "setup": "./scripts/setup.sh", 17 | "setup:local": "./scripts/setup.sh && bun run scripts/local.ts", 18 | "sort-package-json": "sort-package-json", 19 | "teardown": "docker compose down", 20 | "test:e2e": "pnpm exec playwright test --headed", 21 | "test:e2e:ci": "./scripts/e2e.sh", 22 | "test:e2e:ui": "pnpm exec playwright test --ui" 23 | }, 24 | "devDependencies": { 25 | "@playwright/test": "^1.51.1", 26 | "@types/node": "*", 27 | "dotenv": "^16.4.7", 28 | "sort-package-json": "*" 29 | }, 30 | "engines": { 31 | "bun": "1.1.13", 32 | "node": ">=22.11.0", 33 | "pnpm": ">=10.6.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # Formatting package.json files 2 | lerna run sort-package-json 3 | 4 | echo "🐺 Running pre commit hooks..." 5 | 6 | # Check that env vars match 7 | echo "🔍 Checking that env vars match..." 8 | lerna run compare_envs 9 | 10 | # Running tests 11 | echo "🧪 Running tests..." 12 | lerna run test --scope=send-frontend 13 | lerna run test --scope=send-backend 14 | 15 | # Run lint-staged on frontend 16 | echo "🧶 Running lint-staged on frontend..." 17 | 18 | cd packages/send/frontend 19 | cd public 20 | # Check if manifest.json is staged for commit 21 | if git diff --cached --name-only | grep './manifest\.json'; then 22 | # Check if the word "STAGING" is in the staged version 23 | if git show :./manifest.json | grep 'STAGING'; then 24 | echo "❌ Error: 'STAGING' found in manifest.json. This string is only used for development and not to be committed. Please remove it before committing." 25 | exit 1 26 | fi 27 | fi 28 | cd .. 29 | pnpx lint-staged 30 | 31 | # Back to root 32 | cd .. 33 | 34 | # Run lint-staged on backend 35 | echo "🧶 Running lint-staged on backend..." 36 | cd backend && pnpx lint-staged && pnpm typecheck 37 | 38 | # Run typecheck 39 | echo "🔍 Running typecheck..." 40 | pnpm run typecheck -------------------------------------------------------------------------------- /packages/send/backend/tls-dev-proxy/certs/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDDzCCAfegAwIBAgIUHM9EkGk1BtSh9IZfQbQvXh9QNpkwDQYJKoZIhvcNAQEL 3 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMTIwNTIwNDMxM1oXDTI0MTIw 4 | NDIwNDMxM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEA3aBau0YUX+V3qA+TtF4Clx6vCpjy2mslQFQbyQ3WpEiy 6 | g2XB0xraTvHwkVyWR4Q02eewPyMjs/eEQaz0PDOIvtmYlHM/warrAarjX9xb1a47 7 | 23k1ZFeqnGxNzcV/oFwPZSf8ctJvn7iDbuT0jpgXmY9ysfkJwLgdTg5W8HUQSjHG 8 | HU4yqaEapI2NR+dQVydfySErdBp3yuaz15DfVYk5YjtHVDj3Vdoo+UlHe/xsmSx6 9 | M+I5XuyLzYg5DA+rionB+Ax4hsotEo4JwOapDv1qQ6I/wo0WNdv+ESXAkpOeoPtb 10 | TNvWwKstFk4UEUGGKZE2wssWd4maiiEgCDQn1SrlmQIDAQABo1kwVzAUBgNVHREE 11 | DTALgglsb2NhbGhvc3QwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMB 12 | MB0GA1UdDgQWBBR/ensWy8Mo8gVuPQnwEtJEjElrUDANBgkqhkiG9w0BAQsFAAOC 13 | AQEAqqG+smymvwQOwwGhLJ2lG9V8TwMg69/WTILCow5rYoGG3NQBEksa3KDmGAih 14 | yJoyEI+kq2GxX6bU5F/fA2WemwC5pwS4//JKEO2V7ihKhis8nD7IkLdbmN/L2rno 15 | beWaiivInlwaNC636yjmWUNaGW645axH8b6xFgsJh95qVJCB28Nn6VWgBoda+9fH 16 | ZDwJAl4pgpr3FCl06WKGdYDrLQ+xwo23BBjgOQgs5Rsv+xAF7tigHNoq4VjjsoKB 17 | 9T92CmGUSTZ1zy4ASqyG++5C7gV8msvsSZ1pfZPdMzN/PEzVpFwNtCV68reVY+/i 18 | 2fhOgISf7fjXQTdzF8/XzoYIDg== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /packages/send/backend/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | class Logger { 3 | private getTimestamp(): string { 4 | return new Date().toISOString(); 5 | } 6 | 7 | private skipLogging(): boolean { 8 | return process.env.NODE_ENV === 'production'; 9 | } 10 | 11 | private formatMessage(message: string): string { 12 | return `[${this.getTimestamp()}] ${message}`; 13 | } 14 | 15 | /* When the environment is set to dev, we log info, debug and log */ 16 | log(...args: any[]): void { 17 | if (this.skipLogging()) return; 18 | console.log(this.formatMessage(args.join(' '))); 19 | } 20 | info(...args: any[]): void { 21 | if (this.skipLogging()) return; 22 | console.info(this.formatMessage(args.join(' '))); 23 | } 24 | debug(...args: any[]): void { 25 | if (this.skipLogging()) return; 26 | console.debug(this.formatMessage(args.join(' '))); 27 | } 28 | 29 | /* We always log warns and errors */ 30 | warn(...args: any[]): void { 31 | console.warn(this.formatMessage(args.join(' '))); 32 | } 33 | error(...args: any[]): void { 34 | console.error(this.formatMessage(args.join(' '))); 35 | } 36 | } 37 | 38 | export const logger = new Logger(); 39 | -------------------------------------------------------------------------------- /packages/send/pulumi/ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | 3 | # Exclude a variety of commonly ignored directories. 4 | exclude = [ 5 | ".eggs", 6 | ".git", 7 | ".ruff_cache", 8 | ".venv", 9 | "__pycache__", 10 | "__pypackages__", 11 | "venv", 12 | ] 13 | 14 | # Always generate Python 3.12-compatible code. 15 | target-version = "py312" 16 | 17 | [format] 18 | # Prefer single quotes over double quotes. 19 | quote-style = "single" 20 | 21 | [lint] 22 | # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. 23 | select = ["E", "F"] 24 | ignore = [] 25 | 26 | # Allow autofix for all enabled rules (when `--fix`) is provided. 27 | fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] 28 | unfixable = [] 29 | 30 | # Allow unused variables when underscore-prefixed. 31 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 32 | 33 | [lint.flake8-quotes] 34 | inline-quotes = "single" 35 | 36 | [lint.mccabe] 37 | # Unlike Flake8, default to a complexity level of 10. 38 | max-complexity = 10 39 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | export type Environment = 'development' | 'staging' | 'production'; 2 | 3 | export const getIsEnvProd = (envVarObject: Record) => { 4 | return envVarObject?.BASE_URL?.includes('https://send.tb.pro'); 5 | }; 6 | 7 | /** 8 | * Returns true if the environment is production 9 | * @param envVarObject - Object containing environment variables. You can use proces.env or import.meta.env, if executed from vite.config, use env that comes from loadEnv 10 | * @returns boolean indicating if environment is production 11 | */ 12 | export const getEnvironmentName = ( 13 | envVarObject: Record 14 | ): Environment => { 15 | if (!envVarObject) { 16 | throw new Error('Environment variables object is required'); 17 | } 18 | // Development is when process.env.NODE_ENV is not set or set to 'development' 19 | if ((envVarObject.NODE_ENV || envVarObject.MODE) === 'development') { 20 | return 'development'; 21 | } 22 | // Production is when BASE_URL is set to 'tb.pro' 23 | if (getIsEnvProd(envVarObject)) { 24 | return 'production'; 25 | } 26 | // Staging is when BASE_URL is set to 'thunderbird.dev' 27 | return 'staging'; 28 | }; 29 | 30 | export const TRPC_WS_PATH = `/trpc/ws`; 31 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/stores/config-store.ts: -------------------------------------------------------------------------------- 1 | import { getEnvName } from '@/lib/clientConfig'; 2 | import { defineStore } from 'pinia'; 3 | import { computed, ref } from 'vue'; 4 | 5 | export const useConfigStore = defineStore('config', () => { 6 | const environmentName = getEnvName(); 7 | const isProd = environmentName === 'production'; 8 | const isStaging = environmentName === 'staging'; 9 | const isDev = environmentName === 'development'; 10 | 11 | const isExtension = computed(() => { 12 | return location.href.includes('moz-extension:'); 13 | }); 14 | 15 | const _serverUrl = ref(import.meta.env.VITE_SEND_SERVER_URL); 16 | const _isPublicLogin = ref( 17 | import.meta.env.VITE_ALLOW_PUBLIC_LOGIN === 'true' 18 | ); 19 | 20 | const serverUrl = computed(() => _serverUrl.value); 21 | const isPublicLogin = computed(() => _isPublicLogin.value); 22 | 23 | function setServerUrl(url) { 24 | _serverUrl.value = url; 25 | } 26 | 27 | return { 28 | isProd, 29 | isStaging, 30 | isDev, 31 | 32 | // Server 33 | serverUrl, 34 | setServerUrl, 35 | isPublicLogin, 36 | 37 | // Extension 38 | isExtension, 39 | }; 40 | }); 41 | 42 | export type ConfigStore = ReturnType; 43 | -------------------------------------------------------------------------------- /packages/send/scripts/local.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "fs/promises"; 2 | import { join } from "path"; 3 | 4 | /** 5 | * This script modifies the .env files to enable public login functionality for local development. 6 | */ 7 | async function addPublicLoginFlags() { 8 | const rootDir = process.cwd(); 9 | 10 | // Modify backend environment configuration 11 | const backendEnvPath = join(rootDir, "backend", ".env"); 12 | let backendEnv = await readFile(backendEnvPath, "utf-8"); 13 | // Enable public login for backend by changing env variable 14 | backendEnv = backendEnv.replace( 15 | "ALLOW_PUBLIC_LOGIN=false", 16 | "ALLOW_PUBLIC_LOGIN=true" 17 | ); 18 | 19 | await writeFile(backendEnvPath, backendEnv); 20 | 21 | // Modify frontend environment configuration 22 | const frontendEnvPath = join(rootDir, "frontend", ".env"); 23 | let frontendEnv = await readFile(frontendEnvPath, "utf-8"); 24 | // Enable public login for frontend by changing Vite env variable 25 | frontendEnv = frontendEnv.replace( 26 | "VITE_ALLOW_PUBLIC_LOGIN=false", 27 | "VITE_ALLOW_PUBLIC_LOGIN=true" 28 | ); 29 | await writeFile(frontendEnvPath, frontendEnv); 30 | } 31 | 32 | // Execute the script and log any errors that occur 33 | addPublicLoginFlags().catch(console.error); 34 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/mixins/metrics.ts: -------------------------------------------------------------------------------- 1 | import useMetricsStore from '@/stores/metrics'; 2 | import useUserStore from '@/stores/user-store'; 3 | import { onBeforeUnmount, onMounted } from 'vue'; 4 | 5 | /* 6 | * This mixin is used to update the client metrics identity when the user logs in 7 | * we do it on focus because after we close the fxa login window, we have to re-populate the values 8 | */ 9 | export function useMetricsUpdate() { 10 | const userStore = useUserStore(); 11 | const { initializeClientMetrics } = useMetricsStore(); 12 | 13 | const updateMetricsIdentity = async () => { 14 | /* 15 | * TODO: Update this function to check for the hashed value in store, if it's not there, try to populate it from session 16 | * and then initialize the client metrics with the hashed email 17 | * if it's there already, skip initializaiton 18 | */ 19 | await userStore.loadFromLocalStorage(); 20 | const uid = userStore.user.uniqueHash; 21 | initializeClientMetrics(uid); 22 | }; 23 | 24 | onMounted(() => { 25 | window.addEventListener('focus', updateMetricsIdentity); 26 | }); 27 | 28 | onBeforeUnmount(() => { 29 | window.removeEventListener('focus', updateMetricsIdentity); 30 | }); 31 | 32 | return { updateMetricsIdentity }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/clientConfig.ts: -------------------------------------------------------------------------------- 1 | // Anytime we try to access import.meta.env, we need to check if it's running on the client or server 2 | // This function will return true if it's running on the client 3 | function isClientExecution(): boolean { 4 | try { 5 | if (import.meta.env.MODE) return true; 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | } catch (error) { 8 | throw new Error( 9 | 'This code is running on server, it should be executed only on client' 10 | ); 11 | } 12 | } 13 | 14 | export const IS_PROD = isClientExecution() 15 | ? import.meta.env.MODE === 'production' 16 | : false; 17 | export const IS_DEV = isClientExecution() 18 | ? import.meta.env.MODE === 'development' 19 | : false; 20 | 21 | export const getEnvName = () => { 22 | isClientExecution(); 23 | 24 | if (!import.meta.env.BASE_URL && !IS_DEV) { 25 | throw new Error('Environment variables object is required'); 26 | } 27 | 28 | const base_url = import.meta.env.VITE_SEND_CLIENT_URL; 29 | 30 | if (base_url.includes('send.tb.pro')) { 31 | return 'production'; 32 | } 33 | if (base_url.includes('send-stage.tb.pro')) { 34 | return 'staging'; 35 | } 36 | if (base_url.includes('localhost')) { 37 | return 'development'; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/BreadCrumb.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 45 | 46 | 52 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/FolderNavigation.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 36 | -------------------------------------------------------------------------------- /packages/send/frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import eslintConfigPrettier from 'eslint-config-prettier'; 3 | import pluginVue from 'eslint-plugin-vue'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default [ 8 | { 9 | languageOptions: { 10 | parserOptions: { 11 | parser: '@typescript-eslint/parser', 12 | }, 13 | globals: { 14 | ...globals.browser, // Existing browser globals 15 | ...globals.webextensions, // Existing browser globals 16 | __dirname: 'readonly', // Setting __dirname as a global variable 17 | __filename: 'readonly', // Commonly used alongside __dirname 18 | process: 'readonly', // Another common Node.js global 19 | require: 'readonly', // If using CommonJS modules 20 | __APP_VERSION__: 'readonly', // Project version 21 | }, 22 | }, 23 | }, 24 | pluginJs.configs.recommended, 25 | ...tseslint.configs.recommended, 26 | ...pluginVue.configs['flat/recommended'], 27 | eslintConfigPrettier, 28 | { 29 | rules: { 30 | '@typescript-eslint/ban-ts-comment': 'off', 31 | '@typescript-eslint/no-explicit-any': 'warn', 32 | 'no-extra-boolean-cast': 'off', 33 | 'vue/no-use-v-if-with-v-for': 'warn', 34 | }, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /packages/send/backend/src/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import * as trpcExpress from '@trpc/server/adapters/express'; 2 | import { getDataFromAuthenticatedRequest } from '../auth/client'; 3 | import { getStorageLimit } from '../auth/client'; 4 | import { getCookie } from '../utils'; 5 | 6 | export const createContext = ({ 7 | req, 8 | }: trpcExpress.CreateExpressContextOptions) => { 9 | const jwtToken = getCookie(req?.headers?.cookie, 'authorization'); 10 | const jwtRefreshToken = getCookie(req?.headers?.cookie, 'refresh_token'); 11 | 12 | // Make user data available to all trpc requests unless the user is not authenticated 13 | try { 14 | const { id, email, uniqueHash, tier } = 15 | getDataFromAuthenticatedRequest(req); 16 | 17 | const { daysToExpiry, hasLimitedStorage } = getStorageLimit(req); 18 | 19 | return { 20 | user: { 21 | id: id.toString(), 22 | email, 23 | uniqueHash, 24 | tier, 25 | daysToExpiry, 26 | hasLimitedStorage, 27 | }, 28 | cookies: { 29 | jwtToken, 30 | jwtRefreshToken, 31 | }, 32 | }; 33 | } catch { 34 | // If the user is not authenticated, we return only the cookies 35 | return { 36 | user: null, 37 | cookies: { 38 | jwtToken, 39 | jwtRefreshToken, 40 | }, 41 | }; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/routes/containers.routes.test.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import request from 'supertest'; 3 | import { describe, expect, it, vi } from 'vitest'; 4 | import router from '../../routes/containers'; 5 | 6 | const { mockReportUpload } = vi.hoisted(() => ({ 7 | mockReportUpload: vi.fn(), 8 | })); 9 | 10 | const app = express(); 11 | app.use(express.json({ limit: '5mb' })); 12 | app.use(router); 13 | 14 | vi.mock('@/models', () => { 15 | const models = { 16 | reportUpload: mockReportUpload, 17 | }; 18 | return { 19 | default: models, 20 | ...models, 21 | }; 22 | }); 23 | 24 | describe('Containers routes', () => { 25 | it('should log information from client', async () => { 26 | mockReportUpload.mockReturnValue('dummy response'); 27 | 28 | const message = 'reported successfully'; 29 | const mockedUploadId = 'someid'; 30 | const mockedContainerId = 'containerId'; 31 | 32 | const response = await request(app) 33 | .post(`/${mockedContainerId}/report`) 34 | .send({ uploadId: mockedUploadId }) 35 | .set('Content-Type', 'application/json') 36 | .set('Accept', 'application/json'); 37 | 38 | expect(response.body).toEqual({ message }); 39 | expect(response.status).toBe(200); 40 | 41 | expect(mockReportUpload).toBeCalledWith(mockedUploadId); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/DownloadConfirmation.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 49 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/init.ts: -------------------------------------------------------------------------------- 1 | import { INIT_ERRORS } from '@/apps/send/const'; 2 | import { FolderStore } from '@/apps/send/stores/folder-store.types'; 3 | import { Keychain } from '@/lib/keychain'; 4 | import { UserStore } from '@/stores/user-store'; 5 | 6 | /** 7 | * Loads user and keychain from storage; creates default folder if necessary. 8 | * @param {UserStore} userStore - Pinia store for managing user. 9 | * @param {Keychain} keychain - Instance of Keychain class. 10 | * @param {FolderStore} folderStore - Pinia store for managing folders. 11 | * @return {Promise} - Returns Promise of 0 (success) or an error code typed by INIT_ERRORS. 12 | */ 13 | async function init( 14 | userStore: UserStore, 15 | keychain: Keychain, 16 | folderStore: FolderStore 17 | ): Promise { 18 | const hasUser = await userStore.loadFromLocalStorage(); 19 | const hasKeychain = await keychain.load(); 20 | 21 | if (!hasUser) { 22 | return INIT_ERRORS.NO_USER; 23 | } 24 | 25 | if (!hasKeychain) { 26 | return INIT_ERRORS.NO_KEYCHAIN; 27 | } 28 | 29 | await folderStore.sync(); 30 | if (!folderStore?.defaultFolder) { 31 | const createFolderResp = await folderStore.createFolder(); 32 | if (!createFolderResp?.id) { 33 | return INIT_ERRORS.COULD_NOT_CREATE_DEFAULT_FOLDER; 34 | } 35 | } 36 | 37 | return INIT_ERRORS.NONE; 38 | } 39 | 40 | export default init; 41 | -------------------------------------------------------------------------------- /packages/send/frontend/vite.config.extension.js: -------------------------------------------------------------------------------- 1 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import { resolve } from 'path'; 4 | import { defineConfig, loadEnv } from 'vite'; 5 | import { packageJson, sharedViteConfig } from './sharedViteConfig'; 6 | 7 | export default defineConfig(({ mode }) => { 8 | const env = loadEnv(mode, process.cwd()); 9 | return { 10 | ...sharedViteConfig, 11 | plugins: [ 12 | vue(), 13 | sentryVitePlugin({ 14 | org: 'thunderbird', 15 | project: 'send-suite-frontend', 16 | authToken: env.VITE_SENTRY_AUTH_TOKEN, 17 | release: packageJson.version, 18 | moduleMetadata: { 19 | version: packageJson.version, 20 | appHost: 'extension', 21 | }, 22 | }), 23 | ], 24 | resolve: { 25 | alias: { 26 | '@': resolve(__dirname, 'src'), 27 | }, 28 | }, 29 | build: { 30 | minify: true, 31 | sourcemap: true, 32 | outDir: 'dist/extension', 33 | rollupOptions: { 34 | // external: ["vue"], 35 | input: { 36 | extension: resolve(__dirname, 'index.extension.html'), 37 | }, 38 | output: { 39 | entryFileNames: '[name].js', 40 | chunkFileNames: 'chunks/[name].js', 41 | assetFileNames: 'assets/[name].[ext]', 42 | }, 43 | }, 44 | }, 45 | }; 46 | }); 47 | -------------------------------------------------------------------------------- /packages/send/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: "postgres:15" 4 | volumes: 5 | - postgres-data:/var/lib/postgresql/data 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | - POSTGRES_USER=postgres 10 | - POSTGRES_PASSWORD=postgres 11 | - POSTGRES_DB=send-suite 12 | healthcheck: 13 | test: ["CMD-SHELL", "pg_isready -U postgres"] 14 | interval: 10s 15 | timeout: 5s 16 | retries: 5 17 | 18 | backend: 19 | build: 20 | context: ./backend 21 | dockerfile: Dockerfile-dev 22 | ports: 23 | - "8080:8080" 24 | - "5555:5555" # for browsing db with prisma studio 25 | - "9229:9229" # Debugger port 26 | volumes: 27 | - ./backend:/app 28 | - backend-node-modules:/app/node_modules 29 | - ./backend/.env:/app/.env 30 | depends_on: 31 | db: 32 | condition: service_healthy 33 | 34 | reverse-proxy: 35 | build: 36 | context: ./backend/tls-dev-proxy/ 37 | ports: 38 | - "8088:12345" 39 | depends_on: 40 | - backend 41 | 42 | frontend: 43 | build: 44 | context: ./frontend/ 45 | ports: 46 | - "5173:5173" 47 | volumes: 48 | - ./frontend:/app 49 | - frontend-node-modules:/app/node_modules 50 | depends_on: 51 | - backend 52 | volumes: 53 | postgres-data: 54 | backend-node-modules: # do not create on host 55 | frontend-node-modules: # do not create on host 56 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/FileNameForm.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 56 | -------------------------------------------------------------------------------- /packages/send/frontend/src/test/logger.test.ts: -------------------------------------------------------------------------------- 1 | import logger, { loggerPrefix } from '@/logger'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | 4 | describe('Logger module', () => { 5 | it('should log info successfully', async () => { 6 | const message = { content: 'test message' }; 7 | const consoleSpy = vi.spyOn(console, 'log'); 8 | 9 | logger.info(message); 10 | 11 | expect(consoleSpy).toBeCalledWith(`${loggerPrefix.info} ${message}`); 12 | consoleSpy.mockRestore(); 13 | }); 14 | 15 | it('should log log error messages', async () => { 16 | const message = 'A bad error occurred'; 17 | const consoleErrorSpy = vi.spyOn(console, 'error'); 18 | 19 | logger.error(message); 20 | 21 | expect(consoleErrorSpy).toBeCalledWith(`${loggerPrefix.error} ${message}`); 22 | }); 23 | 24 | it('should handle failure to log info', async () => { 25 | const message = { content: 'test message' }; 26 | const consoleErrorSpy = vi.spyOn(console, 'error'); 27 | 28 | logger.error(message); 29 | 30 | expect(consoleErrorSpy).toHaveBeenCalled(); 31 | consoleErrorSpy.mockRestore(); 32 | }); 33 | 34 | it('should handle failure to log info', async () => { 35 | const message = { content: 'test message' }; 36 | const consoleWarnSpy = vi.spyOn(console, 'warn'); 37 | 38 | logger.warn(message); 39 | 40 | expect(consoleWarnSpy).toHaveBeenCalled(); 41 | consoleWarnSpy.mockRestore(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/send/backend/src/routes/metrics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getDataFromAuthenticatedRequest, 3 | getJWTfromToken, 4 | } from '@/auth/client'; 5 | import { Router } from 'express'; 6 | import { useMetrics } from '../metrics'; 7 | import { getUniqueHashFromAnonId } from '../utils/session'; 8 | 9 | const router: Router = Router(); 10 | 11 | router.post('/api/metrics/page-load', (req, res) => { 12 | let uniqueHash = null; 13 | try { 14 | getJWTfromToken(req.headers.authorization); 15 | const dataFromToken = getDataFromAuthenticatedRequest(req); 16 | uniqueHash = dataFromToken.uniqueHash; 17 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 | } catch (error) { 19 | // will use anon_id 20 | } 21 | 22 | const data = req.body; 23 | 24 | const metrics = useMetrics(); 25 | 26 | let anon_id = [req.hostname, data.browser_version, data.os_version].join('-'); 27 | 28 | anon_id = getUniqueHashFromAnonId(anon_id); 29 | 30 | const event = 'page-load'; 31 | const properties = { 32 | ...data, 33 | service: 'send', 34 | }; 35 | 36 | if (uniqueHash) { 37 | metrics.capture({ 38 | distinctId: uniqueHash, 39 | event, 40 | properties, 41 | }); 42 | } else { 43 | metrics.capture({ 44 | distinctId: anon_id, 45 | event, 46 | properties, 47 | }); 48 | } 49 | 50 | res.status(200).json({ 51 | message: uniqueHash || anon_id, 52 | }); 53 | }); 54 | 55 | export default router; 56 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 41 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/metrics/metrics.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { useMetrics } from '../../metrics'; 3 | 4 | const { captureMock } = vi.hoisted(() => ({ 5 | captureMock: vi.fn(), 6 | })); 7 | 8 | vi.mock('posthog-node', () => { 9 | class PostHog { 10 | apiKey: string; 11 | options: string; 12 | capture: typeof captureMock; 13 | debug: () => void; 14 | on: () => void; 15 | 16 | constructor(apiKey, options) { 17 | this.apiKey = apiKey; 18 | this.options = options; 19 | 20 | this.capture = captureMock; 21 | this.debug = vi.fn(); 22 | this.on = vi.fn(); 23 | } 24 | } 25 | return { 26 | PostHog, 27 | }; 28 | }); 29 | 30 | describe('Metrics', () => { 31 | it('should call posthog', async () => { 32 | const Metrics = useMetrics(); 33 | const mockedPayload = { 34 | distinctId: 'test', 35 | event: 'test', 36 | properties: { test: 'test' }, 37 | }; 38 | 39 | Metrics.capture(mockedPayload); 40 | 41 | expect(captureMock).toBeCalledWith(mockedPayload); 42 | }); 43 | 44 | it('should throw an error when no envs for posthog', async () => { 45 | const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {}); 46 | vi.stubEnv('POSTHOG_API_KEY', ''); 47 | vi.stubEnv('POSTHOG_HOST', ''); 48 | 49 | expect(() => useMetrics()).not.toThrow('POSTHOG keys not set'); 50 | expect(warnMock).toBeCalledWith('POSTHOG keys not set'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/vue'; 2 | import { getEnvironmentName } from './config'; 3 | 4 | type App = ReturnType; 5 | 6 | const TRACING_LEVELS_PROD = ['error', 'warn']; 7 | const TRACING_LEVELS_DEV = ['error', 'warn', 'debug']; 8 | 9 | const isProduction = import.meta.env.MODE === 'production'; 10 | 11 | export const initSentry = (app: App) => { 12 | Sentry.init({ 13 | app, 14 | dsn: import.meta.env.VITE_SENTRY_DSN, 15 | integrations: [ 16 | Sentry.browserTracingIntegration(), 17 | Sentry.replayIntegration(), 18 | Sentry.captureConsoleIntegration({ 19 | levels: isProduction ? TRACING_LEVELS_PROD : TRACING_LEVELS_DEV, 20 | }), 21 | ], 22 | // Performance Monitoring 23 | tracesSampleRate: 0.5, 24 | // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled 25 | // tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/], 26 | // Session Replay 27 | replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. 28 | replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. 29 | environment: import.meta.env.MODE, 30 | }); 31 | }; 32 | 33 | Sentry.setTag('environmentName', getEnvironmentName(import.meta.env)); 34 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/AddTag.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 49 | -------------------------------------------------------------------------------- /packages/send/frontend/vite.config.management.js: -------------------------------------------------------------------------------- 1 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import path from 'path'; 4 | import { defineConfig, loadEnv } from 'vite'; 5 | import { packageJson, sharedViteConfig } from './sharedViteConfig'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(({ mode }) => { 9 | const env = loadEnv(mode, process.cwd()); 10 | return { 11 | ...sharedViteConfig, 12 | plugins: [ 13 | vue(), 14 | sentryVitePlugin({ 15 | org: 'thunderbird', 16 | project: 'send-suite-frontend', 17 | authToken: env.VITE_SENTRY_AUTH_TOKEN, 18 | release: packageJson.version, 19 | moduleMetadata: { 20 | version: packageJson.version, 21 | appHost: 'management', 22 | }, 23 | }), 24 | ], 25 | test: { 26 | include: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 27 | globals: true, 28 | }, 29 | resolve: { 30 | alias: { 31 | '@': path.resolve(__dirname, 'src'), 32 | }, 33 | }, 34 | build: { 35 | outDir: 'dist/pages', 36 | sourcemap: true, 37 | minify: true, 38 | rollupOptions: { 39 | input: { 40 | management: path.resolve(__dirname, 'index.management.html'), 41 | }, 42 | output: { 43 | entryFileNames: '[name].js', 44 | chunkFileNames: 'chunks/[name].js', 45 | assetFileNames: 'assets/[name].[ext]', 46 | }, 47 | }, 48 | }, 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /packages/send/backend/src/auth/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { getJWTfromToken } from './client'; 3 | 4 | type Args = { 5 | jwtToken: string; 6 | jwtRefreshToken: string; 7 | }; 8 | 9 | /** 10 | * The output is either 'valid' or 'shouldRefresh' or null. No other string is possible. 11 | **/ 12 | type ValidationResult = 'valid' | 'shouldRefresh' | 'shouldLogin' | null; 13 | 14 | /** 15 | * This function validates the JWT token and returns a string indicating whether the token is valid or not. 16 | * If the token is invalid, it will try to refresh the token. 17 | * @param jwtToken - JWT token 18 | * @param jwtRefreshToken - JWT refresh token 19 | * @returns 'valid' if the token is valid, 'shouldRefresh' if the token needs to be refreshed, or null if the token is missing 20 | **/ 21 | export const validateJWT = ({ 22 | jwtRefreshToken, 23 | jwtToken, 24 | }: Args): ValidationResult => { 25 | const token = getJWTfromToken(jwtToken); 26 | const refreshToken = getJWTfromToken(jwtRefreshToken); 27 | 28 | if (!token && !refreshToken) { 29 | return null; 30 | } 31 | 32 | // validate refresh token 33 | try { 34 | jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET); 35 | } catch { 36 | // Refresh token failed to verify, tell the user to re-login. 37 | return 'shouldLogin'; 38 | } 39 | // validate access token 40 | try { 41 | jwt.verify(token, process.env.ACCESS_TOKEN_SECRET); 42 | } catch { 43 | // catch error and try to refresh token 44 | return 'shouldRefresh'; 45 | } 46 | return 'valid'; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/send/backend/src/types/custom.ts: -------------------------------------------------------------------------------- 1 | // Container permissions 2 | // These are modeled as a bitfield, i.e., 0b00000110 3 | // Allows us to mix and match permissions without 4 | // requiring extra database columns. 5 | export enum PermissionType { 6 | NONE = 0, // 0 7 | READ = 1 << 1, // 2 8 | WRITE = 1 << 2, // 4 9 | SHARE = 1 << 3, // 8 10 | ADMIN = 1 << 4, // 16 11 | } 12 | 13 | export function allPermissions() { 14 | return ( 15 | PermissionType.READ | 16 | PermissionType.WRITE | 17 | PermissionType.SHARE | 18 | PermissionType.ADMIN 19 | ); 20 | } 21 | 22 | function hasPermission(userPermission: PermissionType, pType: PermissionType) { 23 | return userPermission & pType; 24 | } 25 | 26 | export function hasWrite(userPermission: PermissionType) { 27 | return ( 28 | hasPermission(userPermission, PermissionType.WRITE) || 29 | hasPermission(userPermission, PermissionType.ADMIN) 30 | ); 31 | } 32 | 33 | export function hasRead(userPermission: PermissionType) { 34 | return ( 35 | hasPermission(userPermission, PermissionType.READ) || 36 | hasPermission(userPermission, PermissionType.ADMIN) 37 | ); 38 | } 39 | 40 | export function hasAdmin(userPermission: PermissionType) { 41 | return ( 42 | hasPermission(userPermission, PermissionType.READ) || 43 | hasPermission(userPermission, PermissionType.ADMIN) 44 | ); 45 | } 46 | 47 | export function hasShare(userPermission: PermissionType) { 48 | return ( 49 | hasPermission(userPermission, PermissionType.SHARE) || 50 | hasPermission(userPermission, PermissionType.ADMIN) 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/views/Conversations.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 40 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/FolderNameForm.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 59 | -------------------------------------------------------------------------------- /packages/send/backend/src/storage/s3b2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GetObjectCommand, 3 | PutObjectCommand, 4 | S3Client, 5 | } from '@aws-sdk/client-s3'; 6 | import { getSignedUrl as getSignedUrlCommand } from '@aws-sdk/s3-request-presigner'; 7 | 8 | const Bucket = process.env.B2_BUCKET_NAME; 9 | 10 | export async function getClientFromAWSSDK() { 11 | // Configure the S3 client 12 | const s3Client = new S3Client({ 13 | endpoint: `${process.env.B2_ENDPOINT}`, 14 | region: process.env.B2_REGION || 'auto', 15 | credentials: { 16 | accessKeyId: process.env.B2_APPLICATION_KEY_ID, 17 | secretAccessKey: process.env.B2_APPLICATION_KEY, 18 | }, 19 | }); 20 | 21 | return s3Client; 22 | } 23 | 24 | export async function getSignedUrl( 25 | s3Client: S3Client, 26 | Key: string, 27 | ContentType: string 28 | ) { 29 | // Set up the command parameters 30 | const command = new PutObjectCommand({ 31 | Bucket, 32 | Key, 33 | ContentType, 34 | }); 35 | 36 | // Generate the presigned URL (expires in 3600 seconds / 1 hour by default) 37 | const signedUrl = await getSignedUrlCommand(s3Client, command, { 38 | expiresIn: 3600, 39 | }); 40 | return signedUrl; 41 | } 42 | 43 | export async function getSignedUrlforDownload(s3Client: S3Client, Key: string) { 44 | // Set up the command parameters 45 | const command = new GetObjectCommand({ 46 | Bucket, 47 | Key, 48 | }); 49 | 50 | // Generate the presigned URL (expires in 3600 seconds / 1 hour by default) 51 | const signedUrl = await getSignedUrlCommand(s3Client, command, { 52 | expiresIn: 3600, 53 | }); 54 | return signedUrl; 55 | } 56 | -------------------------------------------------------------------------------- /packages/send/frontend/src/stores/keychain-store.ts: -------------------------------------------------------------------------------- 1 | import { Keychain } from '@/lib/keychain'; 2 | import { Storage } from '@/lib/storage'; 3 | import { defineStore } from 'pinia'; 4 | 5 | // TODO: decide if it's worth it to move the internals of the Keychain class to the store. 6 | /* 7 | cons: additional code (store setup) for tests 8 | pros: (more) reactivity? 9 | 10 | */ 11 | 12 | // Providing just enough typing for a keychain-store to be passed 13 | // to init() (in init.ts). 14 | type KeychainStore = { 15 | keychain: Keychain; 16 | resetKeychain: () => void; 17 | addKey: (id: string, key: CryptoKey) => Promise; 18 | getKey: (id: string) => Promise; 19 | removeKey: (id: string) => void; 20 | newKeyForContainer: (id: string) => Promise; 21 | }; 22 | 23 | const useKeychainStore = defineStore('keychain', () => { 24 | const storage = new Storage(); 25 | const keychain = new Keychain(storage); 26 | 27 | function resetKeychain() { 28 | keychain._init(); 29 | } 30 | 31 | async function addKey(id: string, key: CryptoKey) { 32 | await keychain.add(id, key); 33 | } 34 | 35 | async function getKey(id: string) { 36 | return await keychain.get(id); 37 | } 38 | 39 | function removeKey(id: string) { 40 | keychain.remove(id); 41 | } 42 | 43 | async function newKeyForContainer(id: string) { 44 | await keychain.newKeyForContainer(id); 45 | } 46 | 47 | const store: KeychainStore = { 48 | keychain, 49 | resetKeychain, 50 | addKey, 51 | getKey, 52 | removeKey, 53 | newKeyForContainer, 54 | }; 55 | 56 | return store; 57 | }); 58 | 59 | export default useKeychainStore; 60 | -------------------------------------------------------------------------------- /packages/send/backend/tls-dev-proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | error_log /var/log/nginx/error.log warn; 4 | pid /var/run/nginx.pid; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | http { 11 | 12 | # If the upgrade header is set to "websocket", 13 | # then the connection upgrade will take effect. 14 | # Otherwise, it will keep the connection alive 15 | map $http_upgrade $connection_upgrade { 16 | default upgrade; 17 | '' close; 18 | } 19 | 20 | server { 21 | listen 12346; # not used 22 | server_name localhost; 23 | 24 | return 301 https://$server_name$request_uri; 25 | } 26 | 27 | server { 28 | listen 12345 ssl; 29 | server_name localhost; 30 | 31 | ssl_certificate /etc/ssl/certs/localhost.crt; 32 | ssl_certificate_key /etc/ssl/certs/localhost.key; 33 | 34 | # location / { 35 | # proxy_pass http://app:8080; 36 | # proxy_set_header Upgrade $http_upgrade; 37 | # proxy_set_header Connection "Upgrade"; 38 | # proxy_set_header Host $host; 39 | # proxy_http_version 1.1; 40 | # } 41 | location / { 42 | proxy_pass http://backend:8080; 43 | proxy_http_version 1.1; 44 | proxy_set_header Host $host; 45 | proxy_set_header X-Real-IP $remote_addr; 46 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 47 | proxy_set_header X-Forwarded-Proto $scheme; 48 | proxy_set_header Upgrade $http_upgrade; 49 | proxy_cookie_path / "/; HTTPOnly; Secure"; 50 | 51 | proxy_set_header Connection $connection_upgrade; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/chat/components/InvitationList.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 61 | -------------------------------------------------------------------------------- /packages/send/backend/src/ws/setup.ts: -------------------------------------------------------------------------------- 1 | // Configure sentry 2 | import { TRPC_WS_PATH } from '@/config'; 3 | import '../sentry'; 4 | 5 | import { logger } from '@/utils/logger'; 6 | import 'dotenv/config'; 7 | import { wsMessageServer, wss, wsUploadServer } from '../index'; 8 | import wsMsgHandler from '../wsMsgHandler'; 9 | import wsUploadHandler from '../wsUploadHandler'; 10 | 11 | const WS_UPLOAD_PATH = `/api/ws`; 12 | const WS_MESSAGE_PATH = `/api/messagebus`; 13 | 14 | const messageClients = new Map(); 15 | 16 | export const wsHandler = (server) => { 17 | server.on('upgrade', (req, socket, head) => { 18 | if (req.url === WS_UPLOAD_PATH) { 19 | wsUploadServer.handleUpgrade(req, socket, head, (ws) => { 20 | wsUploadServer.emit('connection', ws, req); 21 | wsUploadHandler(ws); 22 | }); 23 | } else if (req.url.startsWith(WS_MESSAGE_PATH)) { 24 | console.info(`upgrading ${WS_MESSAGE_PATH}`); 25 | wsMessageServer.handleUpgrade(req, socket, head, (ws) => { 26 | const parts = req.url.split('/'); 27 | const id = parts[parts.length - 1]; 28 | console.info(id); 29 | messageClients.set(id, ws); 30 | wsMsgHandler(ws, messageClients); 31 | }); 32 | } else if (req.url === TRPC_WS_PATH) { 33 | logger.log(`✅ WebSocket Server listening on ${TRPC_WS_PATH}`); 34 | wss.handleUpgrade(req, socket, head, (ws) => { 35 | wss.emit('connection', ws, req); 36 | wss.on('connection', (ws) => { 37 | logger.log(`➕➕ Connection (${wss.clients.size})`); 38 | ws.once('close', () => { 39 | logger.log(`➖➖ Connection (${wss.clients.size})`); 40 | }); 41 | }); 42 | }); 43 | } 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/ShieldIcon.vue: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /packages/send/backend/src/utils/compatibility.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { checkCompatibility } from './compatibility'; 3 | 4 | describe('checkCompatibility', () => { 5 | it('should return FORCE_UPDATE when major versions differ', () => { 6 | const result = checkCompatibility('2.0', '1.0'); 7 | expect(result).toBe('FORCE_UPDATE'); 8 | }); 9 | 10 | it('should return PROMPT_UPDATE when major versions are the same but minor versions differ', () => { 11 | const result = checkCompatibility('1.2', '1.3'); 12 | expect(result).toBe('PROMPT_UPDATE'); 13 | }); 14 | 15 | it('should return COMPATIBLE when major and minor versions are the same', () => { 16 | const result = checkCompatibility('1.2', '1.2'); 17 | expect(result).toBe('COMPATIBLE'); 18 | }); 19 | 20 | it('should handle versions with patch numbers correctly', () => { 21 | const result = checkCompatibility('1.2.3', '1.2.4'); 22 | expect(result).toBe('COMPATIBLE'); 23 | }); 24 | 25 | it('should handle major version incompatibility', () => { 26 | const result = checkCompatibility('0.2.3', '1.2.4'); 27 | expect(result).toBe('FORCE_UPDATE'); 28 | }); 29 | 30 | it('should return PROMPT_UPDATE on minor version difference', () => { 31 | const result = checkCompatibility('0.2.3', '0.3.3'); 32 | expect(result).toBe('PROMPT_UPDATE'); 33 | }); 34 | 35 | it('should handle multiple different version scenarios', () => { 36 | expect(checkCompatibility('1.0', '1.0')).toBe('COMPATIBLE'); 37 | expect(checkCompatibility('1.0', '1.1')).toBe('PROMPT_UPDATE'); 38 | expect(checkCompatibility('1.0', '2.0')).toBe('FORCE_UPDATE'); 39 | expect(checkCompatibility('2.1', '2.1')).toBe('COMPATIBLE'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/send/frontend/scripts/build.sh: -------------------------------------------------------------------------------- 1 | # Check if environment NODE_ENV has been set to production 2 | if [ "$NODE_ENV" = "production" ]; then 3 | echo 'Starting production build 🐧' 4 | else 5 | echo 'Starting development build 🐣' 6 | fi 7 | 8 | # Pre-build makes sure the ID and name are set on the xpi for prod/stage 9 | bun run scripts/set-id.ts 10 | 11 | # Get version from package.json and replace dots with hyphens 12 | VERSION=$(jq -r .version < package.json | sed 's/\./-/g') 13 | 14 | # Copy css to backend 15 | cp src/apps/send/style.css ../backend/public/style.css 16 | sed -i.bak '1s/^/\/* WARNING THIS IS A SELF GENERATED FILE. ALL CHANGES WILL BE OVERWRITTEN ON BUILD. IF YOU WANT TO MODIFY THE ORIGINAL FILE, PLEASE MODIFY frontend\/public\/style.css *\/\n/' ../backend/public/style.css && rm ../backend/public/style.css.bak 17 | # Copy public folder to backend 18 | cp -R public/icons ../backend/public 19 | 20 | # Remove old builds 21 | rm -rf dist && rm -rf dist-web 22 | rm -rf send-suite 23 | 24 | mkdir -p dist/assets 25 | 26 | ### this should get copied automatically when compiling a page 27 | cp -R public/* dist/ 28 | 29 | ### Extension UI 30 | vite build --config vite.config.extension.js 31 | cp -R dist/extension/assets/* dist/assets/ 32 | cp -R dist/extension/*.* dist/ 33 | rm -rf dist/extension 34 | 35 | ### Management page, commenting out for now 36 | vite build --config vite.config.management.js 37 | cp -R dist/pages/assets/* dist/assets/ 38 | cp -R dist/pages/*.* dist/ 39 | rm -rf dist/pages 40 | 41 | cd dist 42 | 43 | # Create xpi with version number 44 | zip -r -FS ../../send-suite-${VERSION}.xpi * 45 | 46 | echo 'Add-on build complete 🎉' 47 | 48 | echo 'Building web app 🏭' 49 | pnpm exec vite build 50 | 51 | echo 'Web app build complete 🎉' -------------------------------------------------------------------------------- /packages/send/frontend/scripts/submit.sh: -------------------------------------------------------------------------------- 1 | cd .. 2 | 3 | mkdir frontend-source 4 | 5 | # Get version from package.json and replace dots with hyphens 6 | VERSION=$(jq -r .version < frontend/package.json | sed 's/\./-/g') 7 | 8 | # Copy only necessary files 9 | cp -r frontend/src frontend-source/src 10 | cp -r frontend/public frontend-source/public 11 | cp frontend/package.json frontend-source/package.json 12 | cp frontend/tsconfig.json frontend-source/tsconfig.json 13 | cp frontend/.env frontend-source/ 14 | cp frontend/index.extension.html frontend-source/index.extension.html 15 | cp frontend/index.html frontend-source/index.html 16 | cp frontend/index.management.html frontend-source/index.management.html 17 | cp frontend/vite.config.extension.js frontend-source/vite.config.extension.js 18 | cp frontend/vite.config.js frontend-source/vite.config.js 19 | cp frontend/vite.config.management.js frontend-source/vite.config.management.js 20 | cp frontend/tailwind.config.js frontend-source/tailwind.config.js 21 | cp frontend/postcss.config.js frontend-source/postcss.config.js 22 | cp frontend/pnpm-lock.yaml frontend-source/pnpm-lock.yaml 23 | cp frontend/favicon.ico frontend-source/favicon.ico 24 | cp frontend/README.md frontend-source/README.md 25 | mkdir frontend-source/scripts 26 | cp frontend/scripts/build.sh frontend-source/scripts/build.sh 27 | 28 | # Create zip for submission 29 | if [ "$IS_CI_AUTOMATION" != "yes" ]; then 30 | # Is *not* CI automation; is probably local dev 31 | zip -r frontend-source-${VERSION}.zip frontend-source 32 | rm -rf frontend-source 33 | echo "Finished creating frontend-source-${VERSION}.zip!" 34 | else 35 | # *IS* CI automation 36 | zip -r frontend-source.zip frontend-source 37 | echo "Finished creating frontend-source.zip!" 38 | fi 39 | -------------------------------------------------------------------------------- /packages/send/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + Vite 2 | 3 | This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` 53 | 54 | 67 | -------------------------------------------------------------------------------- /packages/send/frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import path from 'path'; 4 | import { defineConfig, loadEnv } from 'vite'; 5 | import { packageJson, sharedViteConfig } from './sharedViteConfig'; 6 | import { getEnvironmentName } from './src/lib/config'; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ mode }) => { 10 | const env = loadEnv(mode, process.cwd()); 11 | 12 | const SERVER_BASE_URLS = { 13 | // backend is the docker network name by default 14 | development: 'http://backend:8080', 15 | production: env.VITE_SEND_SERVER_URL, 16 | }; 17 | 18 | const SERVER_BASE_URL = SERVER_BASE_URLS[mode]; 19 | 20 | return { 21 | ...sharedViteConfig, 22 | plugins: [ 23 | vue(), 24 | sentryVitePlugin({ 25 | org: 'thunderbird', 26 | project: 'send-suite-frontend', 27 | authToken: env.VITE_SENTRY_AUTH_TOKEN, 28 | release: packageJson.version, 29 | moduleMetadata: { 30 | version: packageJson.version, 31 | environment: getEnvironmentName(env), 32 | }, 33 | }), 34 | ], 35 | server: { 36 | // `https: true` gives `Error code: SSL_ERROR_NO_CYPHER_OVERLAP` 37 | // https: true, 38 | proxy: { 39 | // `secure: false` seems to do nothing 40 | // secure: false, 41 | '/lockbox/fxa': SERVER_BASE_URL, // Using `backend` per the docker network name 42 | '/login-success.html': SERVER_BASE_URL, // Using `backend` per the docker network name 43 | '/login-failed.html': SERVER_BASE_URL, // Using `backend` per the docker network name 44 | }, 45 | }, 46 | resolve: { 47 | alias: { 48 | '@': path.resolve(__dirname, 'src'), 49 | }, 50 | }, 51 | build: { 52 | outDir: 'dist-web', 53 | sourcemap: true, 54 | }, 55 | }; 56 | }); 57 | -------------------------------------------------------------------------------- /packages/send/pulumi/Pulumi.ci.yaml: -------------------------------------------------------------------------------- 1 | encryptionsalt: v1:dqO9ve4FztE=:v1:/eyhW1i0UNycdo3+:xh0fNrUP/v8CK2lnjXe0rEf4e/Z2GQ== 2 | config: 3 | send-suite:b2-application-key: 4 | secure: v1:GN1t50/ZV/Hi4mW+:3FTbNQB7JLWLicnc9sEyvBvYFidSx9xJvQ2AUdauVLJLXh1X9rP7svHbVjQnxzo= 5 | send-suite:b2-application-key-id: 6 | secure: v1:Rz2yu+STHreE4onF:0ShmMiv7pz0pHEFTC6Wxsp1eJxFwtngQYiE1xAlVLHzDQhfnc4fISkk= 7 | send-suite:fxa-allow-list: 8 | secure: v1:Z243JCjGidkIWOAe:dGhY53MBOxnHSPB07NjJyT9jm2lcCQtmeN8h1zmvDGo= 9 | send-suite:fxa-client-id: 10 | secure: v1:s0QSnsSw/dkNxcwh:k6Ekdzy0Hg9QHQCo7UTL/Sge/nAp 11 | send-suite:fxa-client-secret: 12 | secure: v1:dqtSXENNuZLG5Sh/:TAp99sm/COVqF2AoRzQjUgXhN3Y= 13 | send-suite:posthog-api-key: 14 | secure: v1:3fRMgg50jMwqlzA5:7VqMN88AYvzkb43iR89c07kawn3qM2u7 15 | send-suite:sentry-auth-token: 16 | secure: v1:bNT27ZenX/BFuUN3:nJBIrM12Ibf17i8YOYKnv3/y519FMMBsHdPm043Rx7TJpylMLbHPEiAr+qARs/mab9EAqHCIk4h2v1faLB4ZFabRjm5tlsZ++5HYHr/eCyktJOCrtuy1g1pN2TEsd04k8kTmMa4eI3zUzHnkfj3C06eR3D/KWGt6aw6ScKp9SushPkSJsdgtMZ3D62xe9C4qlVsvzLFdsDxLu9IWcuidmLJFB81W/b6sKTNf+cKPCuWlp4jPXhjgDKVSBFk645TG2g9wHulTcV/uz1W2BqM67WyGeA== 17 | send-suite:jwt-access-token-secret: 18 | secure: v1:EzRRIbhS0KaYLs8N:vnxISSNryblJ95UDCWScNFv/sBBKgXYjftcDreC103AfbLcjKf88BhYFXP4uQR0SqeSBdVBJ54TdRP+lJwdrao5R5umlfKqlI2+DCfFGksI1VYdTkV2gW/42EyMN1th3K2nYRg== 19 | send-suite:jwt-refresh-token-secret: 20 | secure: v1:wmklnnWakdwj8r1q:CuzUB6v/ft8VdR6wYFU70cit6FJa+w/VPI1VfTN9W+V7hwccg2s2RxJNZJnyO/t6yvXqHBcBvuTQgl5D2WzUv4TxhswDjd7yrWIR4orDjZAQq8Nd3d72Dv8v6X+aWDSbfi0kJw== 21 | send-suite:database-url: 22 | secure: v1:G7wC/POJFk644OxJ:6vwY6ZiRnyfBaCowKxhVmE0pvFGzBK0SajYZPajlAfcbDVtbP6Q0sEAvv3zVaiQ8t38UqZ2xp4LBJEsbuo/gNdr0yc009vbKNeJIv3c2GsFTkV2G8mcoIQP6uOmmTdrG42dNl4oU2NOBimr9OEJx+9QxvrvJecJq5r64ndMF 23 | send-suite:route53_zone_id: 24 | secure: v1:Cfe6xITLXVxcrCRD:HLkOy+FDhCT6naFdaffX4aVLYgZWoyKjx1WOjQ0zGSSPWAo3 25 | -------------------------------------------------------------------------------- /packages/send/backend/.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres:postgres@db:5432/send-suite 2 | BASE_URL=https://localhost:8088 3 | 4 | FXA_REDIRECT_URI=http://localhost:5173/lockbox/fxa 5 | FXA_ENTRYPOINT=tblockbox 6 | FXA_CLIENT_ID=the-actual-client-id 7 | FXA_CLIENT_SECRET=the-real-client-secret 8 | FXA_METRICS_FLOW_URL=https://accounts.stage.mozaws.net/metrics-flow 9 | FXA_MOZ_ISSUER=https://accounts.stage.mozaws.net 10 | 11 | DEVELOPER_TIMEZONE=US/Eastern 12 | 13 | # Logging 14 | ERROR_LOG=logs/error.log 15 | COMBINED_LOG=logs/combined.log 16 | 17 | # Sentry 18 | SENTRY_ORG=thunderbird 19 | SENTRY_PROJECT=send-suite-backend 20 | SENTRY_DSN=https://85b7b08be94b8991ed121578d807f755@o4505428107853824.ingest.us.sentry.io/4507567071232000 21 | SENTRY_AUTH_TOKEN= 22 | FXA_ALLOW_LIST= 23 | 24 | # Storage backend values: 25 | # `fs` stores to the local filesystem 26 | # `s3` stores to S3 compatible storage 27 | # `b2` stores to Backblaze, using the native (non-S3) API 28 | STORAGE_BACKEND=fs 29 | 30 | SEND_BACKEND_CORS_ORIGINS=http://localhost:5173,http://localhost:4173 31 | 32 | FS_LOCAL_DIR= 33 | FS_LOCAL_BUCKET=send-fs-local 34 | 35 | TEST_FS_LOCAL_DIR= 36 | TEST_FS_LOCAL_BUCKET= 37 | 38 | # for Backblaze B2 bucket 39 | B2_REGION= 40 | B2_ENDPOINT= 41 | B2_APPLICATION_KEY_ID= 42 | B2_APPLICATION_KEY= 43 | B2_BUCKET_NAME= 44 | 45 | TEST_B2_REGION= 46 | TEST_B2_ENDPOINT= 47 | TEST_B2_APPLICATION_KEY_ID= 48 | TEST_B2_APPLICATION_KEY= 49 | TEST_B2_BUCKET_NAME= 50 | 51 | # for S3 compatible bucket 52 | S3_REGION= 53 | S3_ENDPOINT= 54 | S3_ACCESS_KEY= 55 | S3_SECRET_KEY= 56 | S3_BUCKET_NAME= 57 | 58 | TEST_S3_REGION= 59 | TEST_S3_ENDPOINT= 60 | TEST_S3_ACCESS_KEY= 61 | TEST_S3_SECRET_KEY= 62 | TEST_S3_BUCKET_NAME= 63 | 64 | # Posthog 65 | POSTHOG_API_KEY=keys 66 | POSTHOG_HOST=https://us.i.posthog.com 67 | 68 | # JWT 69 | ACCESS_TOKEN_SECRET=access_token_secret 70 | REFRESH_TOKEN_SECRET=refresh_token_secret 71 | 72 | ALLOW_PUBLIC_LOGIN=false -------------------------------------------------------------------------------- /packages/send/frontend/scripts/bump.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | interface PackageJson { 5 | version: string; 6 | } 7 | 8 | async function updateManifestConfig(): Promise { 9 | try { 10 | // Define relative paths from current directory 11 | const packageJsonPath = path.resolve(__dirname, '../package.json'); 12 | const manifestPath = path.resolve(__dirname, '../public/manifest.json'); 13 | 14 | // Read and parse package.json 15 | const packageJsonContent = JSON.parse( 16 | fs.readFileSync(packageJsonPath, 'utf8') 17 | ) as PackageJson; 18 | const version = packageJsonContent.version; 19 | 20 | console.log('using version', version); 21 | 22 | // Read the Manifest file as string 23 | const manifestContent = fs.readFileSync(manifestPath, 'utf8'); 24 | 25 | // Regular expression to match the Docker image line 26 | // This assumes the image is defined in a format like: "image: registry/name:version" 27 | const versionRegex = /"version":\s*"([^"]+)"/; 28 | 29 | // Replace the version in the image line 30 | const updatedManifestContent = manifestContent.replace( 31 | versionRegex, 32 | () => `"version": "${version}"` 33 | ); 34 | 35 | // Only write if there were actual changes 36 | if (manifestContent !== updatedManifestContent) { 37 | fs.writeFileSync(manifestPath, updatedManifestContent, 'utf8'); 38 | console.log( 39 | `Successfully updated Manifest config with version ${version}` 40 | ); 41 | } else { 42 | console.log('No updates were necessary in the Manifest config'); 43 | } 44 | } catch (error) { 45 | console.error('Error updating Manifest config:', error); 46 | throw error; 47 | } 48 | } 49 | 50 | // Execute the update function 51 | updateManifestConfig().catch((error) => { 52 | console.error('Failed to update Manifest config:', error); 53 | process.exit(1); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/testutils.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { shouldRunSuite } from './testutils'; 3 | 4 | describe('runOrSkip', () => { 5 | const originalEnv = { ...process.env }; 6 | const originalConsoleWarn = console.warn; 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | let mockConsoleWarn: any; 9 | 10 | beforeEach(() => { 11 | mockConsoleWarn = vi.fn(); 12 | console.warn = mockConsoleWarn; 13 | delete process.env.IS_CI_AUTOMATION; 14 | }); 15 | 16 | afterEach(() => { 17 | process.env = { ...originalEnv }; 18 | console.warn = originalConsoleWarn; 19 | }); 20 | 21 | it('should return true when IS_CI_AUTOMATION is set', () => { 22 | process.env.IS_CI_AUTOMATION = 'true'; 23 | const result = shouldRunSuite({ someKey: '' }, 'test suite'); 24 | expect(result).toBe(true); 25 | expect(mockConsoleWarn).not.toHaveBeenCalled(); 26 | }); 27 | 28 | it('should return true when all config values are truthy', () => { 29 | const result = shouldRunSuite( 30 | { key1: 'value1', key2: 'value2' }, 31 | 'test suite' 32 | ); 33 | expect(result).toBe(true); 34 | expect(mockConsoleWarn).not.toHaveBeenCalled(); 35 | }); 36 | 37 | it('should return false and log warning when some config values are falsy', () => { 38 | const result = shouldRunSuite({ key1: 'value1', key2: '' }, 'test suite'); 39 | expect(result).toBe(false); 40 | expect(mockConsoleWarn).toHaveBeenCalledWith( 41 | 'env variables are not correctly set to run test suite' 42 | ); 43 | }); 44 | 45 | it('should return false and log warning when all config values are falsy', () => { 46 | const result = shouldRunSuite({ key1: '', key2: '' }, 'test suite'); 47 | expect(result).toBe(false); 48 | expect(mockConsoleWarn).toHaveBeenCalledWith( 49 | 'env variables are not correctly set to run test suite' 50 | ); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/download.ts: -------------------------------------------------------------------------------- 1 | import { ProgressTracker } from '@/apps/send/stores/status-store'; 2 | import { ApiConnection } from '@/lib/api'; 3 | import { getBlob } from '@/lib/filesync'; 4 | import { Keychain } from '@/lib/keychain'; 5 | import useMetricsStore from '@/stores/metrics'; 6 | import { trpc } from './trpc'; 7 | 8 | export default class Downloader { 9 | keychain: Keychain; 10 | api: ApiConnection; 11 | constructor(keychain: Keychain, api: ApiConnection) { 12 | this.keychain = keychain; 13 | this.api = api; 14 | } 15 | 16 | async doDownload( 17 | id: string, 18 | folderId: string, 19 | wrappedKeyStr: string, 20 | filename: string, 21 | metrics: ReturnType['metrics'], 22 | progressTracker: ProgressTracker 23 | ): Promise { 24 | if (!id) { 25 | return false; 26 | } 27 | if (!folderId) { 28 | return false; 29 | } 30 | 31 | const wrappingKey = await this.keychain.get(folderId); 32 | 33 | if (!wrappingKey) { 34 | return false; 35 | } 36 | 37 | // Get necessary metadata 38 | const { size, type } = await this.api.call<{ 39 | size: number; 40 | type: string; 41 | }>(`uploads/${id}/metadata`); 42 | if (!size) { 43 | return false; 44 | } 45 | 46 | const contentKey: CryptoKey = 47 | await this.keychain.container.unwrapContentKey( 48 | wrappedKeyStr, 49 | wrappingKey 50 | ); 51 | 52 | const { isBucketStorage } = await trpc.getStorageType.query(); 53 | 54 | try { 55 | await getBlob( 56 | id, 57 | size, 58 | contentKey, 59 | isBucketStorage, 60 | filename, 61 | type, 62 | this.api, 63 | progressTracker 64 | ); 65 | 66 | metrics.capture('download.size', { size, type }); 67 | return true; 68 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 69 | } catch (e) { 70 | return false; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/Received.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 65 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/routes/containers.test.ts: -------------------------------------------------------------------------------- 1 | import { flattenDescendants, TreeNode } from '@/routes/containers'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | const containerNoChild: TreeNode = { 5 | id: '1', 6 | children: [], 7 | }; 8 | const containerWithChild: TreeNode = { 9 | id: '2', 10 | children: [containerNoChild], 11 | }; 12 | const containerWithGrandChild: TreeNode = { 13 | id: '3', 14 | children: [containerWithChild], 15 | }; 16 | 17 | describe('flattenDescendants', () => { 18 | it('should handle null or undefined inputs gracefully', () => { 19 | expect(flattenDescendants(null)).toEqual([]); 20 | expect(flattenDescendants(undefined)).toEqual([]); 21 | }); 22 | 23 | it('should return an empty array for an empty tree', () => { 24 | const emptyTree = {} as TreeNode; 25 | expect(flattenDescendants(emptyTree)).toEqual([]); 26 | }); 27 | 28 | it('should return grandchildren, children and root', () => { 29 | expect(flattenDescendants(containerWithGrandChild)).toEqual([ 30 | '1', 31 | '2', 32 | '3', 33 | ]); 34 | }); 35 | 36 | it('should handle a tree with no children', () => { 37 | expect(flattenDescendants(containerNoChild)).toEqual(['1']); 38 | }); 39 | 40 | it('should flatten a tree with multiple children', () => { 41 | const tree: TreeNode = { 42 | id: '1', 43 | children: [ 44 | { id: '2', children: [] }, 45 | { id: '3', children: [] }, 46 | ], 47 | }; 48 | expect(flattenDescendants(tree)).toEqual(['2', '3', '1']); 49 | }); 50 | 51 | it('should flatten a deeply nested tree', () => { 52 | const tree: TreeNode = { 53 | id: '1', 54 | children: [ 55 | { 56 | id: '2', 57 | children: [ 58 | { id: '4', children: [] }, 59 | { id: '5', children: [] }, 60 | ], 61 | }, 62 | { id: '3', children: [] }, 63 | ], 64 | }; 65 | expect(flattenDescendants(tree)).toEqual(['4', '5', '2', '3', '1']); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/Sent.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 64 | -------------------------------------------------------------------------------- /packages/send/frontend/src/test/apps/lockbox/components/FolderVue.test.ts: -------------------------------------------------------------------------------- 1 | import FolderView from '@/apps/send/components/FolderView.vue'; 2 | import { routes } from '@/apps/send/router'; 3 | import { DayJsKey } from '@/types'; 4 | import { mount } from '@vue/test-utils'; 5 | import { describe, expect, it, vi } from 'vitest'; 6 | import { createRouter, createWebHistory } from 'vue-router'; 7 | 8 | let router; 9 | let wrapper; 10 | 11 | const { goToRootFolderSpy } = vi.hoisted(() => { 12 | return { goToRootFolderSpy: vi.fn() }; 13 | }); 14 | 15 | // Setup testing environment 16 | vi.mock('@/apps/send/stores/folder-store', () => { 17 | return { 18 | esmodule: true, 19 | default: vi.fn(() => ({ 20 | goToRootFolder: goToRootFolderSpy, 21 | })), 22 | }; 23 | }); 24 | vi.useFakeTimers(); 25 | 26 | describe('FolderView', () => { 27 | beforeEach(() => { 28 | router = createRouter({ 29 | history: createWebHistory(), 30 | routes, 31 | }); 32 | 33 | wrapper = mount(FolderView, { 34 | global: { 35 | plugins: [router], 36 | provide: { 37 | //@ts-ignore 38 | [DayJsKey]: () => ({ to: () => 'a while ago' }), 39 | }, 40 | }, 41 | }); 42 | }); 43 | 44 | afterEach(() => { 45 | vi.resetAllMocks(); 46 | }); 47 | 48 | it('renders correctly and reacts to route changes', async () => { 49 | expect(wrapper.text()).toContain('Your Files'); 50 | 51 | // Trigger route change 52 | await router.push({ name: 'folder', params: { id: '0' } }); 53 | await wrapper.vm.$nextTick(); 54 | 55 | // Advance timers for debounced functions 56 | // This is VERY IMPORTANT to make sure the debounced function is called 57 | vi.runAllTimers(); 58 | 59 | // Check if goToRootFolder was called with new id 60 | expect(goToRootFolderSpy).toBeCalledWith('0'); 61 | 62 | await router.push({ name: 'folder', params: { id: '123' } }); 63 | await wrapper.vm.$nextTick(); 64 | vi.runAllTimers(); 65 | 66 | expect(goToRootFolderSpy).toBeCalledWith('123'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/send/backend/src/sentry.ts: -------------------------------------------------------------------------------- 1 | // Import with `import * as Sentry from "@sentry/node"` if you are using ESM 2 | import * as Sentry from '@sentry/node'; 3 | import { nodeProfilingIntegration } from '@sentry/profiling-node'; 4 | import 'dotenv/config'; 5 | import { ENVIRONMENT, getEnvironmentName, VERSION } from './config'; 6 | 7 | const TRACING_LEVELS_PROD = ['error', 'warn']; 8 | const TRACING_LEVELS_DEV = ['error', 'warn', 'debug']; 9 | 10 | const isProduction = ENVIRONMENT === 'production'; 11 | 12 | const ignoredTraces = [{ method: 'GET', endpoint: '/' }]; 13 | 14 | const excludedUserAgents = ['headless', 'elb']; 15 | 16 | // Ignore events from health checks and headless browsers 17 | const hasExcludedUserAgent = (headers: Record) => { 18 | const userAgent = ( 19 | headers['user-agent'] || 20 | headers['User-Agent'] || 21 | '' 22 | ).toLowerCase(); 23 | return excludedUserAgents.some((agent) => userAgent.includes(agent)); 24 | }; 25 | 26 | Sentry.init({ 27 | dsn: process.env.SENTRY_DSN, 28 | integrations: [ 29 | nodeProfilingIntegration(), 30 | Sentry.captureConsoleIntegration({ 31 | levels: isProduction ? TRACING_LEVELS_PROD : TRACING_LEVELS_DEV, 32 | }), 33 | ], 34 | // Performance Monitoring 35 | environment: process.env.NODE_ENV || 'development', 36 | beforeSendTransaction: (event) => { 37 | if (hasExcludedUserAgent(event.request.headers)) { 38 | return null; 39 | } 40 | return event; 41 | }, 42 | tracesSampleRate: 0.5, 43 | tracesSampler: (samplingContext) => { 44 | const shouldTrace = !ignoredTraces.some( 45 | ({ endpoint }) => samplingContext?.attributes['http.route'] === endpoint 46 | ); 47 | 48 | return shouldTrace; 49 | }, 50 | 51 | // Set sampling rate for profiling - this is relative to tracesSampleRate 52 | profilesSampleRate: 1.0, 53 | release: VERSION, 54 | }); 55 | 56 | Sentry.setTag('environmentName', getEnvironmentName()); 57 | 58 | console.log('Sentry initialized with env: ' + ENVIRONMENT); 59 | console.log('Sentry is using release version: ' + VERSION); 60 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/components/ReportContent.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 65 | 66 | 75 | -------------------------------------------------------------------------------- /packages/send/backend/src/config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | type Environment = 'development' | 'production'; 6 | export type EnvironmentName = 'stage' | 'prod' | 'development'; 7 | 8 | export const TRPC_WS_PATH = `/trpc/ws`; 9 | 10 | const appConfig = { 11 | file_dir: `/tmp/send-suite-dev-dir`, 12 | max_file_size: 1024 * 1024 * 1024 * 2.5, 13 | }; 14 | 15 | const packageJson = JSON.parse( 16 | fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8') 17 | ); 18 | 19 | export const VERSION = packageJson.version as string; 20 | 21 | const ENVIRONMENT = process.env.NODE_ENV || ('development' as Environment); 22 | const BASE_URL = process.env.BASE_URL; 23 | 24 | export const IS_ENV_DEV = ENVIRONMENT === 'development'; 25 | export const IS_ENV_PROD = ENVIRONMENT === 'production'; 26 | export const IS_ENV_TEST = process.env.NODE_ENV === 'test'; 27 | export const IS_USING_BUCKET_STORAGE = process.env.STORAGE_BACKEND !== 'fs'; 28 | 29 | // Time constants 30 | const ONE_MINUTE = 60 * 1000; 31 | const FIFTEEN_MINUTES = ONE_MINUTE * 15; 32 | const ONE_DAY = 1; 33 | const ONE_WEEK = ONE_DAY * 7; 34 | 35 | // File expiry time in days 36 | export const DAYS_TO_EXPIRY = 15; 37 | 38 | // We're not enforcing the limit right now, we only use it to display a value on the frontend 39 | const ONE_TB_IN_BYTES = 1 * 1_000 * 1_000 * 1_000 * 1_000; // 1 TB (roughly) 40 | export const TOTAL_STORAGE_LIMIT = ONE_TB_IN_BYTES; 41 | 42 | // JWT expiry 43 | export const JWT_EXPIRY = FIFTEEN_MINUTES; 44 | export const JWT_REFRESH_TOKEN_EXPIRY = ONE_WEEK; 45 | 46 | // Determines how many times a file can be attempted to be downloaded with the wrong password before it gets locked 47 | export const MAX_ACCESS_LINK_RETRIES = 5; 48 | 49 | export function getEnvironmentName(): EnvironmentName { 50 | if (BASE_URL.includes('send-backend.tb.pro')) { 51 | return 'prod'; 52 | } 53 | if (BASE_URL.includes('send-backend-stage.tb.pro')) { 54 | return 'stage'; 55 | } 56 | return 'development'; 57 | } 58 | 59 | export { ENVIRONMENT }; 60 | 61 | export default appConfig; 62 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/SendPage.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 57 | -------------------------------------------------------------------------------- /packages/send/frontend/src/test/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getDaysUntilDate } from '@/lib/utils'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | 4 | describe('getDaysUntilDate', () => { 5 | beforeEach(() => { 6 | // Set fixed date to January 15, 2024 7 | vi.setSystemTime(new Date('2024-01-15')); 8 | }); 9 | 10 | afterEach(() => { 11 | vi.useRealTimers(); 12 | }); 13 | 14 | it('should return positive days for future date', () => { 15 | const futureDate = new Date('2024-01-20'); // 5 days in future 16 | expect(getDaysUntilDate(futureDate)).toBe(5); 17 | }); 18 | 19 | it('should return zero for current date', () => { 20 | const currentDate = new Date('2024-01-15'); 21 | expect(getDaysUntilDate(currentDate)).toBe(0); 22 | }); 23 | 24 | it('should not return negative days for past date', () => { 25 | const pastDate = new Date('2024-01-10'); // 5 days ago 26 | expect(getDaysUntilDate(pastDate)).toBe(0); 27 | }); 28 | 29 | it('should handle date at end of month correctly', () => { 30 | const endOfMonth = new Date('2024-01-31'); 31 | expect(getDaysUntilDate(endOfMonth)).toBe(16); 32 | }); 33 | 34 | it('should handle string dates correctly', () => { 35 | expect(getDaysUntilDate('2024-01-20')).toBe(5); 36 | }); 37 | 38 | it('should handle ISO format dates correctly', () => { 39 | expect(getDaysUntilDate('2024-01-20T15:30:00.000Z')).toBe(6); 40 | }); 41 | 42 | it('should handle timezone differences correctly', () => { 43 | const dateWithTimezone = '2024-01-16T00:00:00+02:00'; // One day ahead but with timezone 44 | expect(getDaysUntilDate(dateWithTimezone)).toBe(1); 45 | }); 46 | 47 | it('should not return fractional days', () => { 48 | const laterInDay = '2024-01-15T23:59:59.999Z'; 49 | expect(getDaysUntilDate(laterInDay)).toBe(1); 50 | }); 51 | 52 | it('should handle invalid date strings', () => { 53 | expect(() => getDaysUntilDate('invalid-date')).not.toThrow(); 54 | }); 55 | 56 | it('should handle invalid date strings', () => { 57 | expect(getDaysUntilDate('invalid-date')).toBe(0); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/send/backend/src/routes/download.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { TRANSFER_ERROR } from '../errors/models'; 3 | import { 4 | addErrorHandling, 5 | DOWNLOAD_ERRORS, 6 | wrapAsyncHandler, 7 | } from '../errors/routes'; 8 | import storage from '../storage'; 9 | 10 | const router: Router = Router(); 11 | 12 | // Security for this route will be addressed in ticket #101 13 | router.get( 14 | '/:id', 15 | addErrorHandling(DOWNLOAD_ERRORS.DOWNLOAD_FAILED), 16 | wrapAsyncHandler(async (req, res) => { 17 | const { id } = req.params; 18 | try { 19 | const contentLength = await storage.length(id); 20 | 21 | const fileStream = await storage.get(id); 22 | 23 | if (!fileStream) { 24 | console.error('fileStream is null'); 25 | return res.status(404).send(TRANSFER_ERROR); 26 | } 27 | 28 | let canceled = false; 29 | 30 | req.on('aborted', () => { 31 | canceled = true; 32 | try { 33 | fileStream.destroy(); 34 | } catch (error) { 35 | console.error(error); 36 | } 37 | }); 38 | 39 | res.writeHead(200, { 40 | 'Content-Type': 'application/octet-stream', 41 | 'Content-Length': contentLength, 42 | }); 43 | fileStream.pipe(res); 44 | 45 | fileStream.on('finish', async () => { 46 | if (canceled) { 47 | return; 48 | } 49 | }); 50 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 51 | } catch (e) { 52 | return res.status(404).send(TRANSFER_ERROR); 53 | } 54 | }) 55 | ); 56 | 57 | /* 58 | * This route is used to get a signed URL for downloading a file. 59 | */ 60 | router.get( 61 | '/:id/signed', 62 | wrapAsyncHandler(async (req, res) => { 63 | const { id } = req.params; 64 | try { 65 | const bucketUrl = await storage.getDownloadBucketUrl(id); 66 | 67 | return res.json({ url: bucketUrl }); 68 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 69 | } catch (e) { 70 | return res.status(404).send(TRANSFER_ERROR); 71 | } 72 | }) 73 | ); 74 | 75 | export default router; 76 | -------------------------------------------------------------------------------- /packages/send/frontend/src/lib/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { JwkKeyPair, StoredKey } from '@/lib/keychain'; 2 | import { UserType } from '@/types'; 3 | import LocalStorageAdapter from './LocalStorage'; 4 | 5 | export interface StorageAdapter { 6 | get: (k: string) => any; 7 | set: (k: string, v: any) => void; 8 | clear: () => void; 9 | } 10 | 11 | export class Storage { 12 | USER_KEY = 'lb/user'; 13 | OTHER_KEYS_KEY = 'lb/keys'; 14 | RSA_KEYS_KEY = 'lb/rsa'; 15 | PASS_PHRASE = 'lb/passphrase'; 16 | adapter: StorageAdapter; 17 | 18 | constructor(Adapter = LocalStorageAdapter) { 19 | this.adapter = new Adapter(); 20 | } 21 | 22 | async storeUser(userObj: UserType): Promise { 23 | this.adapter.set(this.USER_KEY, { ...userObj }); 24 | } 25 | 26 | async getUserFromLocalStorage(): Promise { 27 | return this.adapter.get(this.USER_KEY); 28 | } 29 | 30 | async storeKeys(keysObj: StoredKey): Promise { 31 | this.adapter.set(this.OTHER_KEYS_KEY, { ...keysObj }); 32 | } 33 | 34 | async storePassPhrase(passPhrase: string): Promise { 35 | this.adapter.set(this.PASS_PHRASE, { passPhrase }); 36 | } 37 | 38 | getPassPhrase(): string { 39 | const keys = this.adapter.get(this.PASS_PHRASE); 40 | return keys?.passPhrase || ''; 41 | } 42 | 43 | async loadKeys(): Promise { 44 | return this.adapter.get(this.OTHER_KEYS_KEY); 45 | } 46 | 47 | async storeKeypair(keypair: JwkKeyPair) { 48 | this.adapter.set(this.RSA_KEYS_KEY, { ...keypair }); 49 | } 50 | 51 | async loadKeypair(): Promise { 52 | return this.adapter.get(this.RSA_KEYS_KEY); 53 | } 54 | 55 | async clear(): Promise { 56 | return this.adapter.clear(); 57 | } 58 | 59 | async export() { 60 | // primarily for debugging or moving a user to another device 61 | // prior to getting multiple-device login implemented 62 | const user = await this.getUserFromLocalStorage(); 63 | const keypair = await this.loadKeypair(); 64 | const keys = await this.loadKeys(); 65 | return { 66 | user, 67 | keypair, 68 | keys, 69 | }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/send/pulumi/Pulumi.prod.yaml: -------------------------------------------------------------------------------- 1 | encryptionsalt: v1:seY7tKNLBmE=:v1:wJ9SGUeu2BoLr/8c:880dpePAXi9dU6BCbiq3xNc//gUswA== 2 | config: 3 | send-suite:b2-application-key-id: 4 | secure: v1:9gM4PEbC9731FkzH:V7EoXSmq00rCOnJOOP/uwsscEnI/Hx/6n4qDz0cjPePugDHOKbo94ts= 5 | send-suite:b2-application-key: 6 | secure: v1:bPY/WJIq+i7y3GKI:Kq+NjJVfTkPOH7QqAz80kaMuHqts+ZxkGxh6jJ5JkMnsSpzVk5IW0WPAMmR5eik= 7 | send-suite:database-url: 8 | secure: v1:JEyZ/HkE+ybWFbF7:UmSS1QPoHTSJODyxQ0F+1y2/At0b8vOwWvSn9Zhwy+JhMBb/d5788pY4ia4mEShHl7EixwgMkWxbDrEMgknUYhyTfj50yH9FZv8Y5lk3OLH31DgIYpRr3Dz70krbpio8QwKPoQG+1+qyKxihsof30JrcfVB1Lai7tgLKntRxKM0= 9 | send-suite:fxa-allow-list: 10 | secure: v1:zh2awlDg3/zntRBc:JUHPT5lNHVceLQxl3ocjTuFT/g== 11 | send-suite:fxa-client-id: 12 | secure: v1:OZJ9F52rHuUAZSeL:jskOc93zyI8uZJfYn+BBmfsXjiXI4lxzan137X2eKwE= 13 | send-suite:fxa-client-secret: 14 | secure: v1:TijixNPiB+hjrLzG:mmdCrw1DY1JIEuri6z+PUAujUhEjIJzubPlsdnIapqDxvpSbmbrkBibso5Dbr5Sz0vpHgm3D5/oho33ScZ4zEroqpaGMDiLTneN2HwCPhv0= 15 | send-suite:jwt-access-token-secret: 16 | secure: v1:gy+Gx1fxTMaEkSmP:vKhg//vdL5i+qvmEvUtgLyZXhECKz3bM4J9MnChSVsKc/w47fMGZOQL+W7xmZXyrfEFMMiMpaFP955COef9yvu6QSoTsvMWHA41Gzp3MvxOCUE6F4s3UbBLsDIB9CaY7DvzSHFk= 17 | send-suite:jwt-refresh-token-secret: 18 | secure: v1:QNdm0sUMIAe1NeCx:11WAzcNmVF/gvc9AQBHAXd9t8PBti6KtCSoLU0d9O6RfFZcd9Lfv11O6eWOY/ZTfZFkVLcaYmWq8pEEwzHogNGBV4/BlgSVshAiNCfLJg2RHcKZ2kmGG7lQid9gWB7grLEX5 19 | send-suite:posthog-api-key: 20 | secure: v1:DffkeIw+6ISV8lmR:pHtpRFJyWJbye+XsDuIbbKfhng== 21 | send-suite:sentry-auth-token: 22 | secure: v1:0PLzOnxApK9BJORt:LJzdVHrSaZT35bc+Dg4R3NrSi0t4xDyjsVYTlOlVotyO2E3YBjIOyicv5ZRsDMNvnBqHkcdEy2JtZkAWVJRqLbCjl8CHbJrXtW0FrvA1hWW2bCClB6twQSwc114gkrNEhl2gMu08bF9045Dc+jo5b3vvsNzyndfdPmwcO7eu6D/eyOu4gwgmhyfiOhCllBHMEw2C4domvilrEe3QhCQJ4J4J6Gfgdy/EVIsvoBfqrsNEplFSKQLXqPZv4q74gcQnyhx0fgZPPgQqNl011NyE9nYAFw== 23 | send-suite:cloudflare_zone_id: 24 | secure: v1:ILxVN9y3R+krEhiH:Oc2h1mkyaBIUWwZC+2vGSh9wzBWZ8Pv4vMBOi5A8KmHLfWuELCDp51wcDhy4yT21 25 | cloudflare:apiToken: 26 | secure: v1:/i/PRTJT4/TjZaLr:WYSaS07MmlpcIOFD+x757p+PPewWoBDdpYp1hrFHq+J5GQAdsIx+EmGi2b+TCzbCrl3kx0JZMng= 27 | -------------------------------------------------------------------------------- /packages/send/frontend/src/test/lib/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '@/lib/storage/index'; 2 | import { UserTier } from '@/types'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('User storage', () => { 6 | it('can store a user without error', async () => { 7 | const storage = new Storage(); 8 | const userObj = { 9 | id: 12345, 10 | email: 'ned@ryerson.com', 11 | tier: UserTier.PRO, 12 | }; 13 | expect(async () => { 14 | await storage.storeUser(userObj); 15 | }).not.toThrowError(); 16 | }); 17 | it('can load the same user', async () => { 18 | const storage = new Storage(); 19 | const userObj = { 20 | id: 12345, 21 | email: 'ned@ryerson.com', 22 | tier: UserTier.PRO, 23 | }; 24 | const storedUser = await storage.getUserFromLocalStorage(); 25 | expect(userObj).toEqual(storedUser); 26 | }); 27 | }); 28 | 29 | describe('Key storage', () => { 30 | it('can store keys', async () => { 31 | const storage = new Storage(); 32 | const keys = { 100: 'abc', 102: 'abd', 104: 'abe' }; 33 | expect(async () => { 34 | await storage.storeKeys(keys); 35 | }).not.toThrowError(); 36 | }); 37 | it('can retrieve keys', async () => { 38 | const storage = new Storage(); 39 | const keys = { 100: 'abc', 102: 'abd', 104: 'abe' }; 40 | await storage.storeKeys(keys); 41 | const storedKeys = await storage.loadKeys(); 42 | expect(keys).toEqual(storedKeys); 43 | }); 44 | }); 45 | describe('Keypair storage', () => { 46 | it('can store keypairs', async () => { 47 | const storage = new Storage(); 48 | const keys = { 49 | publicKey: 'abc123', 50 | privateKey: 'xyz789', 51 | }; 52 | expect(async () => { 53 | await storage.storeKeypair(keys); 54 | }).not.toThrowError(); 55 | }); 56 | it('can retrieve keypairs', async () => { 57 | const storage = new Storage(); 58 | const keys = { 59 | publicKey: 'abc123', 60 | privateKey: 'xyz789', 61 | }; 62 | await storage.storeKeys(keys); 63 | const storedKeys = await storage.loadKeypair(); 64 | expect(keys).toEqual(storedKeys); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/stores/status-store.ts: -------------------------------------------------------------------------------- 1 | // stores/counter.js 2 | import { validator } from '@/lib/validations'; 3 | import useApiStore from '@/stores/api-store'; 4 | import useKeychainStore from '@/stores/keychain-store'; 5 | import useUserStore from '@/stores/user-store'; 6 | import { useDebounceFn } from '@vueuse/core'; 7 | import { defineStore } from 'pinia'; 8 | import { computed, ref } from 'vue'; 9 | 10 | export const useStatusStore = defineStore('status', () => { 11 | const { api } = useApiStore(); 12 | const userStore = useUserStore(); 13 | const { keychain } = useKeychainStore(); 14 | 15 | // Download status 16 | const total = ref(0); 17 | const progressed = ref(0); 18 | const error = ref(''); 19 | const text = ref(''); 20 | 21 | const debouncedUpdate = useDebounceFn((updatedValue: number) => { 22 | progressed.value = updatedValue; 23 | }, 1); 24 | 25 | function setText(message: string) { 26 | text.value = message; 27 | } 28 | 29 | function setUploadSize(size: number) { 30 | total.value = size; 31 | } 32 | 33 | function setProgress(number: number) { 34 | console.log('setting progress', number); 35 | debouncedUpdate(number); 36 | } 37 | 38 | function initialize() { 39 | total.value = 0; 40 | progressed.value = 0; 41 | error.value = ''; 42 | text.value = ''; 43 | } 44 | 45 | const percentage = computed(() => { 46 | const result = (progressed.value * 100) / total.value; 47 | if (Number.isNaN(result)) { 48 | return 0; 49 | } 50 | if (result > 100) { 51 | return 100; 52 | } 53 | return Math.round(result); 54 | }); 55 | 56 | const validators = () => validator({ api, keychain, userStore }); 57 | 58 | return { 59 | validators, 60 | setProgress, 61 | setUploadSize, 62 | setText, 63 | progress: { 64 | total, 65 | progressed, 66 | percentage, 67 | error, 68 | text, 69 | initialize, 70 | setProgress, 71 | setUploadSize, 72 | setText, 73 | }, 74 | }; 75 | }); 76 | 77 | export type StatusStore = ReturnType; 78 | export type ProgressTracker = StatusStore['progress']; 79 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/common/CompatibilityBanner.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 55 | 56 | 78 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/ExtensionPage.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 50 | 51 | 68 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/storage/backblaze.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { 4 | StorageAdapterConfig, 5 | StorageType, 6 | } from '@tweedegolf/storage-abstraction'; 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import { FileStore } from '../../storage'; 10 | import { shouldRunSuite } from '../testutils'; 11 | 12 | const config: StorageAdapterConfig = { 13 | type: StorageType.B2, 14 | bucketName: process.env.TEST_B2_BUCKET_NAME, 15 | applicationKeyId: process.env.TEST_B2_APPLICATION_KEY_ID, 16 | applicationKey: process.env.TEST_B2_APPLICATION_KEY, 17 | }; 18 | 19 | describe.runIf(shouldRunSuite(config, `Storage: Backblaze B2`))( 20 | `Storage: Backblaze B2`, 21 | () => { 22 | const mockFile = 'file.txt'; 23 | const mockDataDir = path.join(__dirname, 'data/'); 24 | const mockFilePath = path.join(mockDataDir, mockFile); 25 | 26 | const storage = new FileStore(config); 27 | 28 | it('should write a file to b2 bucket', async () => { 29 | const fileName = `write-${new Date().getTime()}.txt`; 30 | 31 | const result = await storage.set( 32 | fileName, 33 | fs.createReadStream(mockFilePath) 34 | ); 35 | expect(result).toBeTruthy(); 36 | }); 37 | 38 | it('should read a file from b2 bucket', async () => { 39 | const fileName = `read-${new Date().getTime()}.txt`; 40 | 41 | const writeResult = await storage.set( 42 | fileName, 43 | fs.createReadStream(mockFilePath) 44 | ); 45 | expect(writeResult).toBeTruthy(); 46 | 47 | const readResult = await storage.get(fileName); 48 | expect(readResult).toBeTruthy(); 49 | }); 50 | 51 | it('should delete a file from b2 bucket', async () => { 52 | const fileName = `delete-${new Date().getTime()}.txt`; 53 | 54 | const writeResult = await storage.set( 55 | fileName, 56 | fs.createReadStream(mockFilePath) 57 | ); 58 | expect(writeResult).toBeTruthy(); 59 | 60 | const readResult = await storage.get(fileName); 61 | expect(readResult).toBeTruthy(); 62 | 63 | const deleteResult = await storage.del(fileName); 64 | expect(deleteResult).toBeTruthy(); 65 | }); 66 | } 67 | ); 68 | -------------------------------------------------------------------------------- /packages/send/frontend/src/apps/send/elements/BtnComponent.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 33 | 34 | 106 | -------------------------------------------------------------------------------- /packages/send/backend/src/test/storage/s3.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { 4 | StorageAdapterConfig, 5 | StorageType, 6 | } from '@tweedegolf/storage-abstraction'; 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import { FileStore } from '../../storage'; 10 | import { shouldRunSuite } from '../testutils'; 11 | 12 | const config: StorageAdapterConfig = { 13 | type: StorageType.S3, 14 | region: process.env.TEST_S3_REGION || 'auto', 15 | bucketName: process.env.TEST_S3_BUCKET_NAME, 16 | endpoint: process.env.TEST_S3_ENDPOINT, 17 | accessKeyId: process.env.TEST_S3_ACCESS_KEY, 18 | secretAccessKey: process.env.TEST_S3_SECRET_KEY, 19 | }; 20 | 21 | describe.runIf(shouldRunSuite(config, 'Storage: S3-compatible'))( 22 | `Storage: S3-compatible`, 23 | () => { 24 | const mockFile = 'file.txt'; 25 | const mockDataDir = path.join(__dirname, 'data/'); 26 | const mockFilePath = path.join(mockDataDir, mockFile); 27 | 28 | const storage = new FileStore(config); 29 | 30 | it('should write a file to s3 bucket', async () => { 31 | const fileName = `write-${new Date().getTime()}.txt`; 32 | 33 | const result = await storage.set( 34 | fileName, 35 | fs.createReadStream(mockFilePath) 36 | ); 37 | expect(result).toBeTruthy(); 38 | }); 39 | 40 | it('should read a file from s3 bucket', async () => { 41 | const fileName = `read-${new Date().getTime()}.txt`; 42 | 43 | const writeResult = await storage.set( 44 | fileName, 45 | fs.createReadStream(mockFilePath) 46 | ); 47 | expect(writeResult).toBeTruthy(); 48 | 49 | const readResult = await storage.get(fileName); 50 | expect(readResult).toBeTruthy(); 51 | }); 52 | 53 | it('should delete a file from s3 bucket', async () => { 54 | const fileName = `delete-${new Date().getTime()}.txt`; 55 | 56 | const writeResult = await storage.set( 57 | fileName, 58 | fs.createReadStream(mockFilePath) 59 | ); 60 | expect(writeResult).toBeTruthy(); 61 | 62 | const readResult = await storage.get(fileName); 63 | expect(readResult).toBeTruthy(); 64 | 65 | const deleteResult = await storage.del(fileName); 66 | expect(deleteResult).toBeTruthy(); 67 | }); 68 | } 69 | ); 70 | -------------------------------------------------------------------------------- /packages/send/e2e/send.spec.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, expect, Page, test } from "@playwright/test"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import { log_out_restore_keys, register_and_login } from "./pages/dashboard"; 5 | import { 6 | delete_file, 7 | download_workflow, 8 | share_links, 9 | upload_workflow, 10 | } from "./pages/myFiles"; 11 | import { setup_browser } from "./testUtils"; 12 | 13 | export type PlaywrightProps = { 14 | context: BrowserContext; 15 | page: Page; 16 | }; 17 | 18 | export const storageStatePath = path.resolve( 19 | __dirname, 20 | "../data/lockboxstate.json" 21 | ); 22 | const emptyState = { 23 | cookies: [], 24 | origins: [], 25 | }; 26 | 27 | // Configure tests to run serially with retries 28 | test.describe.configure({ mode: "serial", retries: 3 }); 29 | 30 | // Cleanup storage state after all tests 31 | test.afterAll(async () => { 32 | fs.writeFileSync(storageStatePath, JSON.stringify(emptyState)); 33 | }); 34 | 35 | // Authentication-related tests 36 | const authTests = [ 37 | { title: "Register and log in", path: "/send", action: register_and_login }, 38 | { 39 | title: "Restores keys", 40 | path: "/send/profile", 41 | action: log_out_restore_keys, 42 | }, 43 | ]; 44 | 45 | test.describe("Authentication", () => { 46 | authTests.forEach(({ title, path, action }) => { 47 | test(title, async () => { 48 | const { context, page } = await setup_browser(); 49 | await page.goto(path); 50 | await action({ context, page }); 51 | }); 52 | }); 53 | }); 54 | 55 | // File workflow tests with shared setup 56 | test.describe("File workflows", () => { 57 | let page: Page; 58 | let context: BrowserContext; 59 | 60 | test.beforeEach(async () => { 61 | ({ page, context } = await setup_browser()); 62 | await page.goto("/send"); 63 | await expect(page).toHaveTitle(/Thunderbird Send/); 64 | }); 65 | 66 | const workflows = [ 67 | { title: "Share links", action: share_links }, 68 | { title: "Upload workflow", action: upload_workflow }, 69 | { title: "Download workflow", action: download_workflow }, 70 | { title: "Delete files", action: delete_file }, 71 | ]; 72 | 73 | workflows.forEach(({ title, action }) => { 74 | test(title, async () => await action({ page, context })); 75 | }); 76 | }); 77 | --------------------------------------------------------------------------------