├── .circleci └── config.yml ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .gitattributes ├── .gitignore ├── .htmllintrc ├── .prettierignore ├── .stylelintrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTORS ├── Dockerfile ├── LICENSE ├── README.md ├── android ├── .eslintrc.yaml ├── .gitignore ├── README.md ├── SendAndroid.iml ├── android.js ├── app │ ├── .gitignore │ ├── build.gradle │ ├── buildAssets.sh │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── ic_launcher-web.png │ │ ├── java │ │ └── org │ │ │ └── mozilla │ │ │ └── firefoxsend │ │ │ └── MainActivity.kt │ │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_foreground.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── pages │ ├── .eslintrc.yaml │ ├── error.js │ ├── home.js │ ├── preferences.js │ ├── share.js │ └── upload.js ├── settings.gradle ├── stores │ ├── intents.js │ └── state.js └── user.js ├── app ├── .eslintrc.yml ├── api.js ├── archive.js ├── capabilities.js ├── controller.js ├── crc32.js ├── dragManager.js ├── ece.js ├── experiments.js ├── fileReceiver.js ├── fileSender.js ├── fxa.js ├── keychain.js ├── locale.js ├── main.css ├── main.js ├── metrics.js ├── ownedFile.js ├── pasteManager.js ├── readme.md ├── routes.js ├── serviceWorker.js ├── storage.js ├── streams.js ├── ui │ ├── account.js │ ├── archiveTile.js │ ├── blank.js │ ├── body.js │ ├── copyDialog.js │ ├── download.js │ ├── downloadCompleted.js │ ├── downloadDialog.js │ ├── downloadPassword.js │ ├── error.js │ ├── expiryOptions.js │ ├── footer.js │ ├── header.js │ ├── home.js │ ├── intro.js │ ├── modal.js │ ├── noStreams.js │ ├── notFound.js │ ├── okDialog.js │ ├── report.js │ ├── selectbox.js │ ├── shareDialog.js │ └── unsupported.js ├── user.js ├── utils.js └── zip.js ├── assets ├── add.svg ├── addfiles.svg ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── blue_file.svg ├── close-16.svg ├── completed.svg ├── copy-16.svg ├── dl.svg ├── error.svg ├── favicon-16x16.png ├── favicon-32x32.png ├── feedback.svg ├── firefox_logo-only.svg ├── icon.svg ├── intro.svg ├── lock.svg ├── master-logo.svg ├── notFound.svg ├── safari-pinned-tab.svg ├── select-arrow.svg ├── share-24.svg ├── user.svg └── wordmark.svg ├── browserslist ├── build ├── android_index_plugin.js ├── readme.md └── version_plugin.js ├── common ├── assets.js ├── generate_asset_map.js └── readme.md ├── docker-compose.yml ├── docs ├── CODEOWNERS ├── acceptance-mobile.md ├── acceptance-web.md ├── build.md ├── deployment.md ├── docker.md ├── encryption.md ├── experiments.md ├── faq.md ├── localization.md ├── metrics.md ├── notes │ └── streams.md └── takedowns.md ├── ios ├── generate-bundle.js ├── ios.js ├── send-ios-action-extension │ ├── ActionViewController.swift │ ├── Base.lproj │ │ └── MainInterface.storyboard │ └── Info.plist ├── send-ios.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcuserdata │ │ │ └── donovan.xcuserdatad │ │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ │ └── donovan.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist └── send-ios │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ ├── ViewController.swift │ ├── assets │ ├── background_1.jpg │ ├── index.css │ └── index.html │ └── help.html ├── l10n.toml ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── Inter-Black.woff ├── Inter-Black.woff2 ├── Inter-BlackItalic.woff ├── Inter-BlackItalic.woff2 ├── Inter-Bold.woff ├── Inter-Bold.woff2 ├── Inter-BoldItalic.woff ├── Inter-BoldItalic.woff2 ├── Inter-ExtraBold.woff ├── Inter-ExtraBold.woff2 ├── Inter-ExtraBoldItalic.woff ├── Inter-ExtraBoldItalic.woff2 ├── Inter-ExtraLight.woff ├── Inter-ExtraLight.woff2 ├── Inter-ExtraLightItalic.woff ├── Inter-ExtraLightItalic.woff2 ├── Inter-Italic.woff ├── Inter-Italic.woff2 ├── Inter-Light.woff ├── Inter-Light.woff2 ├── Inter-LightItalic.woff ├── Inter-LightItalic.woff2 ├── Inter-Medium.woff ├── Inter-Medium.woff2 ├── Inter-MediumItalic.woff ├── Inter-MediumItalic.woff2 ├── Inter-Regular.woff ├── Inter-Regular.woff2 ├── Inter-SemiBold.woff ├── Inter-SemiBold.woff2 ├── Inter-SemiBoldItalic.woff ├── Inter-SemiBoldItalic.woff2 ├── Inter-Thin.woff ├── Inter-Thin.woff2 ├── Inter-ThinItalic.woff ├── Inter-ThinItalic.woff2 ├── Inter-italic.var.woff2 ├── Inter-upright.var.woff2 ├── Inter.var.woff2 ├── contribute.json ├── favicon.ico ├── inter.css └── locales │ ├── an │ └── send.ftl │ ├── ar │ └── send.ftl │ ├── ast │ └── send.ftl │ ├── az │ └── send.ftl │ ├── azz │ └── send.ftl │ ├── be │ └── send.ftl │ ├── bn │ └── send.ftl │ ├── br │ └── send.ftl │ ├── bs │ └── send.ftl │ ├── ca │ └── send.ftl │ ├── cak │ └── send.ftl │ ├── ckb │ └── send.ftl │ ├── cs │ └── send.ftl │ ├── cy │ └── send.ftl │ ├── da │ └── send.ftl │ ├── de │ └── send.ftl │ ├── dsb │ └── send.ftl │ ├── el │ └── send.ftl │ ├── en-CA │ └── send.ftl │ ├── en-GB │ └── send.ftl │ ├── en-US │ └── send.ftl │ ├── es-AR │ └── send.ftl │ ├── es-CL │ └── send.ftl │ ├── es-ES │ └── send.ftl │ ├── es-MX │ └── send.ftl │ ├── et │ └── send.ftl │ ├── eu │ └── send.ftl │ ├── fa │ └── send.ftl │ ├── fi │ └── send.ftl │ ├── fr │ └── send.ftl │ ├── fy-NL │ └── send.ftl │ ├── gn │ └── send.ftl │ ├── gor │ └── send.ftl │ ├── he │ └── send.ftl │ ├── hr │ └── send.ftl │ ├── hsb │ └── send.ftl │ ├── hu │ └── send.ftl │ ├── hus │ └── send.ftl │ ├── hy-AM │ └── send.ftl │ ├── ia │ └── send.ftl │ ├── id │ └── send.ftl │ ├── ig │ └── send.ftl │ ├── it │ └── send.ftl │ ├── ixl │ └── send.ftl │ ├── ja │ └── send.ftl │ ├── ka │ └── send.ftl │ ├── kab │ └── send.ftl │ ├── ko │ └── send.ftl │ ├── lt │ └── send.ftl │ ├── lus │ └── send.ftl │ ├── meh │ └── send.ftl │ ├── mix │ └── send.ftl │ ├── ml │ └── send.ftl │ ├── ms │ └── send.ftl │ ├── nb-NO │ └── send.ftl │ ├── nl │ └── send.ftl │ ├── nn-NO │ └── send.ftl │ ├── oc │ └── send.ftl │ ├── pa-IN │ └── send.ftl │ ├── pai │ └── send.ftl │ ├── pl │ └── send.ftl │ ├── ppl │ └── send.ftl │ ├── pt-BR │ └── send.ftl │ ├── pt-PT │ └── send.ftl │ ├── quc │ └── send.ftl │ ├── ro │ └── send.ftl │ ├── ru │ └── send.ftl │ ├── sk │ └── send.ftl │ ├── sl │ └── send.ftl │ ├── sn │ └── send.ftl │ ├── sq │ └── send.ftl │ ├── sr │ └── send.ftl │ ├── su │ └── send.ftl │ ├── sv-SE │ └── send.ftl │ ├── te │ └── send.ftl │ ├── th │ └── send.ftl │ ├── tl │ └── send.ftl │ ├── tr │ └── send.ftl │ ├── trs │ └── send.ftl │ ├── uk │ └── send.ftl │ ├── vi │ └── send.ftl │ ├── yo │ └── send.ftl │ ├── yua │ └── send.ftl │ ├── zgh │ └── send.ftl │ ├── zh-CN │ └── send.ftl │ └── zh-TW │ └── send.ftl ├── scripts ├── .eslintrc.yml ├── bin │ └── run-integration-test-circleci.sh ├── get-prod-locales.js ├── lint-locales.js └── sync-npm-dependencies.sh ├── server ├── amplitude.js ├── bin │ ├── dev.js │ ├── prod.js │ └── test.js ├── clientConstants.js ├── config.js ├── fxa.js ├── initScript.js ├── keychain.js ├── layout.js ├── limiter.js ├── locale.js ├── log.js ├── metadata.js ├── middleware │ ├── auth.js │ └── language.js ├── readme.md ├── routes │ ├── delete.js │ ├── done.js │ ├── download.js │ ├── exists.js │ ├── filelist.js │ ├── index.js │ ├── info.js │ ├── metadata.js │ ├── metrics.js │ ├── pages.js │ ├── params.js │ ├── password.js │ ├── report.js │ ├── token.js │ ├── upload.js │ ├── webmanifest.js │ └── ws.js ├── state.js └── storage │ ├── fs.js │ ├── gcs.js │ ├── index.js │ ├── redis.js │ └── s3.js ├── tailwind.config.js ├── test ├── .eslintrc.yml ├── backend │ ├── auth-tests.js │ ├── delete-tests.js │ ├── info-tests.js │ ├── language-tests.js │ ├── metadata-tests.js │ ├── owner-tests.js │ ├── params-tests.js │ ├── password-tests.js │ ├── s3-tests.js │ └── storage-tests.js ├── frontend │ ├── .eslintrc.yml │ ├── index.js │ ├── routes.js │ ├── runner.js │ └── tests │ │ ├── api-tests.js │ │ ├── auth-tests.js │ │ ├── crypto-tests.js │ │ ├── fileSender-tests.js │ │ ├── keychain-tests.js │ │ ├── streaming-tests.js │ │ └── workflow-tests.js ├── integration │ ├── README.md │ ├── download-tests.js │ ├── fixtures │ │ ├── txt-larger-testfile.txt │ │ └── txt-small-testfile.txt │ ├── homepage-tests.js │ ├── pages │ │ └── desktop │ │ │ ├── download_page.js │ │ │ ├── home_page.js │ │ │ └── page.js │ ├── progress-tests.js │ └── send-test.html ├── readme.md ├── testServer.js ├── wdio.circleci.conf.js ├── wdio.common.conf.js ├── wdio.docker.conf.js ├── wdio.local.conf.js ├── wdio.remote.config.js └── wdio.saucelabs.config.js └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .nyc_output 3 | .vscode 4 | .DS_Store 5 | coverage 6 | docs 7 | firefox 8 | node_modules -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.{js,html,yml,json,handlebars}] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.toml] 13 | indent_style = space 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | assets 3 | firefox 4 | coverage 5 | android/app/build 6 | app/locale.js 7 | app/capabilities.js -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | node: true 4 | 5 | extends: 6 | - eslint:recommended 7 | - prettier 8 | - plugin:node/recommended 9 | - plugin:security/recommended 10 | 11 | plugins: 12 | - node 13 | - security 14 | 15 | root: true 16 | 17 | rules: 18 | node/no-deprecated-api: off 19 | node/no-unsupported-features/es-syntax: off 20 | node/no-unsupported-features/node-builtins: off 21 | node/no-unpublished-require: off 22 | node/no-unpublished-import: off 23 | 24 | security/detect-non-literal-fs-filename: off 25 | security/detect-object-injection: off 26 | 27 | no-unused-vars: [error, {argsIgnorePattern: "^_|err|event|next|reject"}] 28 | require-atomic-updates: warn 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | public/locales/* linguist-documentation 2 | docs/* linguist-documentation 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .idea 5 | .DS_Store 6 | .nyc_output 7 | .tox 8 | .pytest_cache 9 | *.iml 10 | android/app/src/main/assets 11 | ios/send-ios/assets/ios.js 12 | ios/send-ios/assets/vendor.js 13 | ios/send-ios.xcodeproj/project.xcworkspace/xcuserdata/* 14 | ios/send-ios.xcodeproj/xcuserdata/* 15 | test/integration/downloads 16 | -------------------------------------------------------------------------------- /.htmllintrc: -------------------------------------------------------------------------------- 1 | { 2 | "attr-name-style": "dash", 3 | "id-class-style": "dash", 4 | "indent-width": 2 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | android/app/src/main/assets 3 | android/app/build 4 | coverage -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | extends: stylelint-config-standard 2 | 3 | plugins: 4 | - stylelint-no-unsupported-browser-features 5 | 6 | rules: 7 | plugin/no-unsupported-browser-features: [true, {severity: warning}] 8 | 9 | color-hex-case: lower 10 | declaration-colon-newline-after: null 11 | selector-list-comma-newline-after: null 12 | value-list-comma-newline-after: null 13 | at-rule-no-unknown: null 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## 2 | # Firefox Send - Mozilla 3 | # 4 | # License https://github.com/mozilla/send/blob/master/LICENSE 5 | ## 6 | 7 | 8 | # Build project 9 | FROM node:12 AS builder 10 | RUN set -x \ 11 | # Add user 12 | && addgroup --gid 10001 app \ 13 | && adduser --disabled-password \ 14 | --gecos '' \ 15 | --gid 10001 \ 16 | --home /app \ 17 | --uid 10001 \ 18 | app 19 | COPY --chown=app:app . /app 20 | USER app 21 | WORKDIR /app 22 | RUN set -x \ 23 | # Build 24 | && PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm ci \ 25 | && npm run build 26 | 27 | 28 | # Main image 29 | FROM node:12-slim 30 | RUN set -x \ 31 | # Add user 32 | && addgroup --gid 10001 app \ 33 | && adduser --disabled-password \ 34 | --gecos '' \ 35 | --gid 10001 \ 36 | --home /app \ 37 | --uid 10001 \ 38 | app 39 | RUN apt-get update && apt-get -y install \ 40 | git-core \ 41 | && rm -rf /var/lib/apt/lists/* 42 | USER app 43 | WORKDIR /app 44 | COPY --chown=app:app package*.json ./ 45 | COPY --chown=app:app app app 46 | COPY --chown=app:app common common 47 | COPY --chown=app:app public/locales public/locales 48 | COPY --chown=app:app server server 49 | COPY --chown=app:app --from=builder /app/dist dist 50 | 51 | RUN npm ci --production && npm cache clean --force 52 | RUN mkdir -p /app/.config/configstore 53 | RUN ln -s dist/version.json version.json 54 | 55 | ENV PORT=1443 56 | 57 | EXPOSE ${PORT} 58 | 59 | CMD ["node", "server/bin/prod.js"] 60 | -------------------------------------------------------------------------------- /android/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | 4 | parserOptions: 5 | sourceType: module 6 | 7 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | local.properties 2 | .gradle 3 | build 4 | 5 | -------------------------------------------------------------------------------- /android/README.md: -------------------------------------------------------------------------------- 1 | Readme 2 | ===== 3 | 4 | The Send Android app allows you to choose any file from your android device, encrypt it with a password, and get a URL which will allow secure download of the file. By default, this URL will expire after one download or 24 hours. 5 | 6 | Building the Send Android app. 7 | ===== 8 | 9 | First, install Android Studio. Open the `android` directory in Android Studio, plug in your android phone, and press the run button. -------------------------------------------------------------------------------- /android/SendAndroid.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | android { 6 | compileSdkVersion 27 7 | defaultConfig { 8 | applicationId "org.mozilla.firefoxsend" 9 | minSdkVersion 26 10 | targetSdkVersion 27 11 | versionCode 1 12 | versionName "1.0" 13 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 14 | } 15 | buildTypes { 16 | release { 17 | minifyEnabled false 18 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 19 | } 20 | } 21 | } 22 | 23 | dependencies { 24 | implementation fileTree(dir: 'libs', include: ['*.jar']) 25 | implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 26 | implementation 'com.android.support:appcompat-v7:27.1.1' 27 | implementation 'com.android.support.constraint:constraint-layout:1.1.3' 28 | testImplementation 'junit:junit:4.12' 29 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 30 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 31 | implementation 'com.github.delight-im:Android-AdvancedWebView:v3.0.0' 32 | implementation "org.mozilla.components:service-firefox-accounts:$android_components_version" 33 | } 34 | 35 | task generateAndLinkBundle(type: Exec, description: 'Generate the android.js bundle and link it into the assets directory') { 36 | commandLine './buildAssets.sh' 37 | } 38 | 39 | tasks.withType(JavaCompile) { 40 | compileTask -> compileTask.dependsOn generateAndLinkBundle 41 | } 42 | -------------------------------------------------------------------------------- /android/app/buildAssets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | if [ -d "../../node_modules" ] 3 | then 4 | echo "node_modules already present." 5 | else 6 | echo "node_modules not present, running npm install." 7 | npm install 8 | fi 9 | npm run build 10 | rm -rf src/main/assets 11 | mkdir -p src/main/assets 12 | cp -R ../../dist/* src/main/assets 13 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /android/app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #220033 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Send 3 | 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext.kotlin_version = '1.3.21' 5 | ext.android_components_version = '0.26.0' 6 | repositories { 7 | google() 8 | jcenter() 9 | } 10 | dependencies { 11 | classpath 'com.android.tools.build:gradle:3.3.2' 12 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.21" 13 | } 14 | } 15 | 16 | allprojects { 17 | repositories { 18 | google() 19 | maven { url "https://maven.mozilla.org/maven2" } 20 | jcenter() 21 | maven { url "https://jitpack.io" } 22 | } 23 | } 24 | 25 | task clean(type: Delete) { 26 | delete rootProject.buildDir 27 | } 28 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Feb 19 08:34:25 EST 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip 7 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /android/pages/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | 4 | parserOptions: 5 | sourceType: module 6 | 7 | -------------------------------------------------------------------------------- /android/pages/error.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | export default function error(_state, _emit) { 4 | return html` 5 | 6 |
7 |

Error

8 |

Sorry, an error occurred.

9 |
10 | 11 | `; 12 | } 13 | -------------------------------------------------------------------------------- /android/pages/home.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const { list } = require('../../app/utils'); 3 | const archiveTile = require('../../app/ui/archiveTile'); 4 | const modal = require('../../app/ui/modal'); 5 | const intro = require('../../app/ui/intro'); 6 | const assets = require('../../common/assets'); 7 | 8 | module.exports = function(state, emit) { 9 | function onchange(event) { 10 | event.preventDefault(); 11 | const newFiles = Array.from(event.target.files); 12 | 13 | emit('addFiles', { files: newFiles }); 14 | } 15 | 16 | function onclick() { 17 | document.getElementById('file-upload').click(); 18 | } 19 | 20 | const archives = state.storage.files 21 | .filter(archive => !archive.expired) 22 | .map(archive => archiveTile(state, emit, archive)) 23 | .reverse(); 24 | 25 | let content = ''; 26 | let button = html` 27 |
32 | 33 |
34 | `; 35 | if (state.uploading) { 36 | content = archiveTile.uploading(state, emit); 37 | button = ''; 38 | } else if (state.archive.numFiles > 0) { 39 | content = archiveTile.wip(state, emit); 40 | button = ''; 41 | } else { 42 | content = 43 | archives.length < 1 44 | ? intro(state) 45 | : list(archives, 'h-full overflow-y-auto w-full', 'mb-3 w-full'); 46 | } 47 | 48 | return html` 49 |
50 | ${state.modal && modal(state, emit)} 51 |
54 | ${content} 55 |
56 |
57 | ${button} 58 | 66 |
67 |
68 | `; 69 | }; 70 | -------------------------------------------------------------------------------- /android/pages/preferences.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | import { setFileProtocolWssUrl, getFileProtocolWssUrl } from '../../app/api'; 4 | 5 | export default function preferences(state, emit) { 6 | const wssURL = getFileProtocolWssUrl(); 7 | 8 | function updateWssUrl(event) { 9 | state.wssURL = event.target.value; 10 | setFileProtocolWssUrl(state.wssURL); 11 | emit('render'); 12 | } 13 | 14 | function clickDone(event) { 15 | event.preventDefault(); 16 | emit('pushState', '/'); 17 | } 18 | 19 | return html` 20 | 21 |
22 |
23 | done 24 |
25 |
wss url:
26 |
27 | 28 |
29 |
30 |
31 |
32 | 33 | `; 34 | } 35 | -------------------------------------------------------------------------------- /android/pages/share.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | export default function uploadComplete(state, emit) { 4 | const file = state.storage.files[state.storage.files.length - 1]; 5 | function onclick(e) { 6 | e.preventDefault(); 7 | input.select(); 8 | document.execCommand('copy'); 9 | input.selectionEnd = input.selectionStart; 10 | copyText.textContent = 'Copied!'; 11 | setTimeout(function() { 12 | copyText.textContent = 'Copy link'; 13 | }, 2000); 14 | } 15 | 16 | function uploadFile(event) { 17 | event.preventDefault(); 18 | const target = event.target; 19 | const file = target.files[0]; 20 | if (file.size === 0) { 21 | return; 22 | } 23 | 24 | emit('pushState', '/upload'); 25 | emit('addFiles', { files: [file] }); 26 | emit('upload', {}); 27 | } 28 | 29 | const input = html` 30 | 31 | `; 32 | const copyText = html` 33 | Copy link 34 | `; 35 | return html` 36 |
37 |
38 |
The card contents will be here.
39 |
Expires after: exp
40 | ${input} 41 | 45 | 48 | 49 |
50 | `; 51 | } 52 | -------------------------------------------------------------------------------- /android/pages/upload.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | export default function progressBar(state, emit) { 4 | let percent = 0; 5 | if (state.transfer && state.transfer.progress) { 6 | percent = Math.floor(state.transfer.progressRatio * 100); 7 | } 8 | function onclick(e) { 9 | e.preventDefault(); 10 | if (state.uploading) { 11 | emit('cancel'); 12 | } 13 | emit('pushState', '/'); 14 | } 15 | return html` 16 | 17 |
18 |
19 |
${percent}%
20 | . 21 |
CANCEL
22 |
23 |
24 | 25 | `; 26 | } 27 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /android/stores/intents.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | export default function intentHandler(state, emitter) { 4 | window.addEventListener( 5 | 'message', 6 | event => { 7 | if (typeof event.data !== 'string' || !event.data.startsWith('data:')) { 8 | return; 9 | } 10 | fetch(event.data) 11 | .then(res => res.blob()) 12 | .then(blob => { 13 | emitter.emit('addFiles', { files: [blob] }); 14 | emitter.emit('upload', {}); 15 | }) 16 | .catch(e => console.error('ERROR ' + e + ' ' + e.stack)); 17 | }, 18 | false 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /android/stores/state.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import User from '../user'; 4 | import storage from '../../app/storage'; 5 | 6 | export default function initialState(state, emitter) { 7 | const files = []; 8 | 9 | Object.assign(state, { 10 | prefix: '/android_asset', 11 | user: new User(storage), 12 | getAsset(name) { 13 | return `${state.prefix}/${name}`; 14 | }, 15 | sentry: { 16 | captureException: e => { 17 | console.error('ERROR ' + e + ' ' + e.stack); 18 | } 19 | }, 20 | storage: { 21 | files, 22 | remove: function(fileId) { 23 | console.log('REMOVE FILEID', fileId); 24 | }, 25 | writeFile: function(file) { 26 | console.log('WRITEFILE', file); 27 | }, 28 | addFile: function(file) { 29 | console.log('addfile' + JSON.stringify(file)); 30 | files.push(file); 31 | emitter.emit('pushState', `/share/${file.id}`); 32 | }, 33 | totalUploads: 0 34 | }, 35 | transfer: null, 36 | uploading: false, 37 | settingPassword: false, 38 | passwordSetError: null, 39 | route: '/' 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /android/user.js: -------------------------------------------------------------------------------- 1 | /* global Android */ 2 | import User from '../app/user'; 3 | import { deriveFileListKey } from '../app/fxa'; 4 | 5 | export default class AndroidUser extends User { 6 | constructor(storage, limits) { 7 | super(storage, limits); 8 | } 9 | 10 | async login() { 11 | Android.beginOAuthFlow(); 12 | } 13 | 14 | startAuthFlow() { 15 | return Promise.resolve(); 16 | } 17 | 18 | async finishLogin(accountInfo) { 19 | const jwks = JSON.parse(accountInfo.keys); 20 | const ikm = jwks['https://identity.mozilla.com/apps/send'].k; 21 | const profile = { 22 | displayName: accountInfo.displayName, 23 | email: accountInfo.email, 24 | avatar: accountInfo.avatar, 25 | access_token: accountInfo.accessToken 26 | }; 27 | profile.fileListKey = await deriveFileListKey(ikm); 28 | this.info = profile; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | node: true 4 | 5 | parserOptions: 6 | sourceType: module 7 | 8 | rules: 9 | node/no-unsupported-features: off 10 | -------------------------------------------------------------------------------- /app/archive.js: -------------------------------------------------------------------------------- 1 | import { blobStream, concatStream } from './streams'; 2 | 3 | function isDupe(newFile, array) { 4 | for (const file of array) { 5 | if ( 6 | newFile.name === file.name && 7 | newFile.size === file.size && 8 | newFile.lastModified === file.lastModified 9 | ) { 10 | return true; 11 | } 12 | } 13 | return false; 14 | } 15 | 16 | export default class Archive { 17 | constructor(files = [], defaultTimeLimit = 86400) { 18 | this.files = Array.from(files); 19 | this.defaultTimeLimit = defaultTimeLimit; 20 | this.timeLimit = defaultTimeLimit; 21 | this.dlimit = 1; 22 | this.password = null; 23 | } 24 | 25 | get name() { 26 | return this.files.length > 1 ? 'Send-Archive.zip' : this.files[0].name; 27 | } 28 | 29 | get type() { 30 | return this.files.length > 1 ? 'send-archive' : this.files[0].type; 31 | } 32 | 33 | get size() { 34 | return this.files.reduce((total, file) => total + file.size, 0); 35 | } 36 | 37 | get numFiles() { 38 | return this.files.length; 39 | } 40 | 41 | get manifest() { 42 | return { 43 | files: this.files.map(file => ({ 44 | name: file.name, 45 | size: file.size, 46 | type: file.type 47 | })) 48 | }; 49 | } 50 | 51 | get stream() { 52 | return concatStream(this.files.map(file => blobStream(file))); 53 | } 54 | 55 | addFiles(files, maxSize, maxFiles) { 56 | if (this.files.length + files.length > maxFiles) { 57 | throw new Error('tooManyFiles'); 58 | } 59 | const newFiles = files.filter( 60 | file => file.size > 0 && !isDupe(file, this.files) 61 | ); 62 | const newSize = newFiles.reduce((total, file) => total + file.size, 0); 63 | if (this.size + newSize > maxSize) { 64 | throw new Error('fileTooBig'); 65 | } 66 | this.files = this.files.concat(newFiles); 67 | return true; 68 | } 69 | 70 | remove(file) { 71 | const index = this.files.indexOf(file); 72 | if (index > -1) { 73 | this.files.splice(index, 1); 74 | } 75 | } 76 | 77 | clear() { 78 | this.files = []; 79 | this.dlimit = 1; 80 | this.timeLimit = this.defaultTimeLimit; 81 | this.password = null; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/dragManager.js: -------------------------------------------------------------------------------- 1 | export default function(state, emitter) { 2 | emitter.on('DOMContentLoaded', () => { 3 | document.body.addEventListener('dragover', event => { 4 | if (state.route === '/') { 5 | event.preventDefault(); 6 | } 7 | }); 8 | document.body.addEventListener('drop', event => { 9 | if ( 10 | state.route === '/' && 11 | !state.uploading && 12 | event.dataTransfer && 13 | event.dataTransfer.files 14 | ) { 15 | event.preventDefault(); 16 | emitter.emit('addFiles', { 17 | files: Array.from(event.dataTransfer.files) 18 | }); 19 | } 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /app/experiments.js: -------------------------------------------------------------------------------- 1 | import hash from 'string-hash'; 2 | import Account from './ui/account'; 3 | 4 | const experiments = { 5 | signin_button_color: { 6 | eligible: function() { 7 | return true; 8 | }, 9 | variant: function() { 10 | return ['white-blue', 'blue', 'white-violet', 'violet'][ 11 | Math.floor(Math.random() * 4) 12 | ]; 13 | }, 14 | run: function(variant, state) { 15 | const account = state.cache(Account, 'account'); 16 | account.buttonClass = variant; 17 | } 18 | } 19 | }; 20 | 21 | //Returns a number between 0 and 1 22 | // eslint-disable-next-line no-unused-vars 23 | function luckyNumber(str) { 24 | return hash(str) / 0xffffffff; 25 | } 26 | 27 | function checkExperiments(state, emitter) { 28 | const all = Object.keys(experiments); 29 | const id = all.find(id => experiments[id].eligible(state)); 30 | if (id) { 31 | const variant = experiments[id].variant(state); 32 | state.storage.enroll(id, variant); 33 | experiments[id].run(variant, state, emitter); 34 | } 35 | } 36 | 37 | export default function initialize(state, emitter) { 38 | emitter.on('DOMContentLoaded', () => { 39 | const xp = experiments[state.query.x]; 40 | if (xp) { 41 | xp.run(+state.query.v, state, emitter); 42 | } 43 | }); 44 | const enrolled = state.storage.enrolled; 45 | // single experiment per session for now 46 | const id = Object.keys(enrolled)[0]; 47 | if (Object.keys(experiments).includes(id)) { 48 | experiments[id].run(enrolled[id], state, emitter); 49 | } else { 50 | checkExperiments(state, emitter); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/locale.js: -------------------------------------------------------------------------------- 1 | import { FluentBundle } from '@fluent/bundle'; 2 | 3 | function makeBundle(locale, ftl) { 4 | const bundle = new FluentBundle(locale, { useIsolating: false }); 5 | bundle.addMessages(ftl); 6 | return bundle; 7 | } 8 | 9 | export async function getTranslator(locale) { 10 | const bundles = []; 11 | const { default: en } = await import('../public/locales/en-US/send.ftl'); 12 | if (locale !== 'en-US') { 13 | const { default: ftl } = await import( 14 | `../public/locales/${locale}/send.ftl` 15 | ); 16 | bundles.push(makeBundle(locale, ftl)); 17 | } 18 | bundles.push(makeBundle('en-US', en)); 19 | return function(id, data) { 20 | for (let bundle of bundles) { 21 | if (bundle.hasMessage(id)) { 22 | return bundle.format(bundle.getMessage(id), data); 23 | } 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /app/main.js: -------------------------------------------------------------------------------- 1 | /* global DEFAULTS LIMITS PREFS */ 2 | import 'core-js'; 3 | import 'fast-text-encoding'; // MS Edge support 4 | import 'intl-pluralrules'; 5 | import choo from 'choo'; 6 | import nanotiming from 'nanotiming'; 7 | import routes from './routes'; 8 | import getCapabilities from './capabilities'; 9 | import controller from './controller'; 10 | import dragManager from './dragManager'; 11 | import pasteManager from './pasteManager'; 12 | import storage from './storage'; 13 | import metrics from './metrics'; 14 | import experiments from './experiments'; 15 | import * as Sentry from '@sentry/browser'; 16 | import './main.css'; 17 | import User from './user'; 18 | import { getTranslator } from './locale'; 19 | import Archive from './archive'; 20 | import { setTranslate, locale } from './utils'; 21 | 22 | if (navigator.doNotTrack !== '1' && window.SENTRY_CONFIG) { 23 | Sentry.init(window.SENTRY_CONFIG); 24 | } 25 | 26 | if (process.env.NODE_ENV === 'production') { 27 | nanotiming.disabled = true; 28 | } 29 | 30 | (async function start() { 31 | const capabilities = await getCapabilities(); 32 | if ( 33 | !capabilities.crypto && 34 | window.location.pathname !== '/unsupported/crypto' 35 | ) { 36 | return window.location.assign('/unsupported/crypto'); 37 | } 38 | if (capabilities.serviceWorker) { 39 | try { 40 | await navigator.serviceWorker.register('/serviceWorker.js'); 41 | await navigator.serviceWorker.ready; 42 | } catch (e) { 43 | // continue but disable streaming downloads 44 | capabilities.streamDownload = false; 45 | } 46 | } 47 | 48 | const translate = await getTranslator(locale()); 49 | setTranslate(translate); 50 | // eslint-disable-next-line require-atomic-updates 51 | window.initialState = { 52 | LIMITS, 53 | DEFAULTS, 54 | PREFS, 55 | archive: new Archive([], DEFAULTS.EXPIRE_SECONDS), 56 | capabilities, 57 | translate, 58 | storage, 59 | sentry: Sentry, 60 | user: new User(storage, LIMITS, window.AUTH_CONFIG), 61 | transfer: null, 62 | fileInfo: null, 63 | locale: locale() 64 | }; 65 | 66 | const app = routes(choo({ hash: true })); 67 | // eslint-disable-next-line require-atomic-updates 68 | window.app = app; 69 | app.use(experiments); 70 | app.use(metrics); 71 | app.use(controller); 72 | app.use(dragManager); 73 | app.use(pasteManager); 74 | app.mount('body'); 75 | })(); 76 | -------------------------------------------------------------------------------- /app/pasteManager.js: -------------------------------------------------------------------------------- 1 | function getString(item) { 2 | return new Promise(resolve => { 3 | item.getAsString(resolve); 4 | }); 5 | } 6 | 7 | export default function(state, emitter) { 8 | window.addEventListener('paste', async event => { 9 | if (state.route !== '/' || state.uploading) return; 10 | if (['password', 'text', 'email'].includes(event.target.type)) return; 11 | 12 | const items = Array.from(event.clipboardData.items); 13 | const transferFiles = items.filter(item => item.kind === 'file'); 14 | const strings = items.filter(item => item.kind === 'string'); 15 | if (transferFiles.length) { 16 | const promises = transferFiles.map(async (f, i) => { 17 | const blob = f.getAsFile(); 18 | if (!blob) { 19 | return null; 20 | } 21 | const name = await getString(strings[i]); 22 | const file = new File([blob], name, { type: blob.type }); 23 | return file; 24 | }); 25 | const files = (await Promise.all(promises)).filter(f => !!f); 26 | if (files.length) { 27 | emitter.emit('addFiles', { files }); 28 | } 29 | } else if (strings.length) { 30 | strings[0].getAsString(s => { 31 | const file = new File([s], 'pasted.txt', { type: 'text/plain' }); 32 | emitter.emit('addFiles', { files: [file] }); 33 | }); 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /app/readme.md: -------------------------------------------------------------------------------- 1 | # Application Code 2 | 3 | `app/` contains the browser code that gets bundled into `app.[hash].js`. It's got all the logic, crypto, and UI. All of it gets used in the browser, and some of it by the server for server side rendering. 4 | 5 | The main entrypoint for the browser is [main.js](./main.js) and on the server [routes.js](./routes.js) is imported by [/server/routes/pages.js](../server/routes/pages.js) 6 | 7 | - `pages` contains display logic an markup for pages 8 | - `routes` contains route definitions and logic 9 | - `templates` contains ui elements smaller than pages 10 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo'); 2 | const download = require('./ui/download'); 3 | const body = require('./ui/body'); 4 | 5 | module.exports = function(app = choo({ hash: true })) { 6 | app.route('/', body(require('./ui/home'))); 7 | app.route('/download/:id', body(download)); 8 | app.route('/download/:id/:key', body(download)); 9 | app.route('/unsupported/:reason', body(require('./ui/unsupported'))); 10 | app.route('/error', body(require('./ui/error'))); 11 | app.route('/blank', body(require('./ui/blank'))); 12 | app.route('/oauth', function(state, emit) { 13 | emit('authenticate', state.query.code, state.query.state); 14 | }); 15 | app.route('/login', function(state, emit) { 16 | emit('replaceState', '/'); 17 | setTimeout(() => emit('render')); 18 | }); 19 | app.route('/report', body(require('./ui/report'))); 20 | app.route('*', body(require('./ui/notFound'))); 21 | return app; 22 | }; 23 | -------------------------------------------------------------------------------- /app/ui/blank.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = function() { 4 | return html` 5 |
6 |
9 |
10 |
11 |
12 |
13 | `; 14 | }; 15 | -------------------------------------------------------------------------------- /app/ui/body.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const Header = require('./header'); 3 | const Footer = require('./footer'); 4 | 5 | module.exports = function body(main) { 6 | return function(state, emit) { 7 | const b = html` 8 | 11 | ${state.cache(Header, 'header').render()} ${main(state, emit)} 12 | ${state.cache(Footer, 'footer').render()} 13 | 14 | `; 15 | if (state.layout) { 16 | // server side only 17 | return state.layout(state, b); 18 | } 19 | return b; 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /app/ui/copyDialog.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const { copyToClipboard } = require('../utils'); 3 | 4 | module.exports = function(name, url) { 5 | const dialog = function(state, emit, close) { 6 | return html` 7 | 10 |

11 | ${state.translate('notifyUploadEncryptDone')} 12 |

13 |

14 | ${state.translate('copyLinkDescription')}
15 | ${name} 16 |

17 | 24 | 31 | 38 |
39 | `; 40 | 41 | function copy(event) { 42 | event.stopPropagation(); 43 | copyToClipboard(url); 44 | event.target.textContent = state.translate('copiedUrl'); 45 | setTimeout(close, 1000); 46 | } 47 | }; 48 | dialog.type = 'copy'; 49 | return dialog; 50 | }; 51 | -------------------------------------------------------------------------------- /app/ui/downloadCompleted.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const assets = require('../../common/assets'); 3 | 4 | module.exports = function(state) { 5 | const btnText = state.user.loggedIn ? 'okButton' : 'sendYourFilesLink'; 6 | return html` 7 |
11 |

12 | ${state.translate('downloadFinish')} 13 |

14 | 15 |

21 | ${state.translate('trySendDescription')} 22 |

23 |

24 | ${state.translate(btnText)} 27 |

28 |

29 | ${state.translate('reportFile')} 30 |

31 |
32 | `; 33 | }; 34 | -------------------------------------------------------------------------------- /app/ui/downloadDialog.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = function() { 4 | return function(state, emit, close) { 5 | const archive = state.fileInfo; 6 | return html` 7 | 10 |

11 | ${state.translate('downloadConfirmTitle')} 12 |

13 |

16 | ${state.translate('downloadConfirmDescription')} 17 |

18 |
19 | 25 | 28 |
29 | 38 | ${state.translate('reportFile')} 41 |
42 | `; 43 | 44 | function toggleDownloadEnabled(event) { 45 | event.stopPropagation(); 46 | const checked = event.target.checked; 47 | const btn = document.getElementById('download-btn'); 48 | btn.disabled = !checked; 49 | } 50 | 51 | function download(event) { 52 | event.preventDefault(); 53 | close(); 54 | event.target.disabled = true; 55 | emit('download', archive); 56 | } 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /app/ui/error.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const assets = require('../../common/assets'); 3 | const modal = require('./modal'); 4 | 5 | module.exports = function(state, emit) { 6 | const btnText = state.user.loggedIn ? 'okButton' : 'sendYourFilesLink'; 7 | return html` 8 |
9 | ${state.modal && modal(state, emit)} 10 |
13 |

14 | ${state.translate('errorPageHeader')} 15 |

16 | 17 |

23 | ${state.translate('trySendDescription')} 24 |

25 |

26 | ${state.translate(btnText)} 29 |

30 |
31 |
32 | `; 33 | }; 34 | -------------------------------------------------------------------------------- /app/ui/expiryOptions.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const raw = require('choo/html/raw'); 3 | const { secondsToL10nId } = require('../utils'); 4 | const selectbox = require('./selectbox'); 5 | 6 | module.exports = function(state, emit) { 7 | const el = html` 8 |
9 | ${raw( 10 | state.translate('archiveExpiryInfo', { 11 | downloadCount: 12 | '', 13 | timespan: '' 14 | }) 15 | )} 16 |
17 | `; 18 | if (el.__encoded) { 19 | // we're rendering on the server 20 | return el; 21 | } 22 | 23 | const counts = state.DEFAULTS.DOWNLOAD_COUNTS.filter( 24 | i => state.capabilities.account || i <= state.user.maxDownloads 25 | ); 26 | 27 | const dlCountSelect = el.querySelector('#dlCount'); 28 | el.replaceChild( 29 | selectbox( 30 | state.archive.dlimit, 31 | counts, 32 | num => state.translate('downloadCount', { num }), 33 | value => { 34 | const max = state.user.maxDownloads; 35 | state.archive.dlimit = Math.min(value, max); 36 | if (value > max) { 37 | emit('signup-cta', 'count'); 38 | } else { 39 | emit('render'); 40 | } 41 | }, 42 | 'expire-after-dl-count-select' 43 | ), 44 | dlCountSelect 45 | ); 46 | 47 | const expires = state.DEFAULTS.EXPIRE_TIMES_SECONDS.filter( 48 | i => state.capabilities.account || i <= state.user.maxExpireSeconds 49 | ); 50 | 51 | const timeSelect = el.querySelector('#timespan'); 52 | el.replaceChild( 53 | selectbox( 54 | state.archive.timeLimit, 55 | expires, 56 | num => { 57 | const l10n = secondsToL10nId(num); 58 | return state.translate(l10n.id, l10n); 59 | }, 60 | value => { 61 | const max = state.user.maxExpireSeconds; 62 | state.archive.timeLimit = Math.min(value, max); 63 | if (value > max) { 64 | emit('signup-cta', 'time'); 65 | } else { 66 | emit('render'); 67 | } 68 | }, 69 | 'expire-after-time-select' 70 | ), 71 | timeSelect 72 | ); 73 | 74 | return el; 75 | }; 76 | -------------------------------------------------------------------------------- /app/ui/footer.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const Component = require('choo/component'); 3 | 4 | class Footer extends Component { 5 | constructor(name, state) { 6 | super(name); 7 | this.state = state; 8 | } 9 | 10 | update() { 11 | return false; 12 | } 13 | 14 | createElement() { 15 | return html` 16 |
19 | `; 20 | } 21 | } 22 | 23 | module.exports = Footer; 24 | -------------------------------------------------------------------------------- /app/ui/header.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const Component = require('choo/component'); 3 | const Account = require('./account'); 4 | const assets = require('../../common/assets'); 5 | const { platform } = require('../utils'); 6 | 7 | class Header extends Component { 8 | constructor(name, state, emit) { 9 | super(name); 10 | this.state = state; 11 | this.emit = emit; 12 | this.account = state.cache(Account, 'account'); 13 | } 14 | 15 | update() { 16 | this.account.render(); 17 | return false; 18 | } 19 | createElement() { 20 | const title = 21 | platform() === 'android' 22 | ? html` 23 | 24 | 25 | 26 | 27 | 28 | 29 | ` 30 | : html` 31 | 32 | ${this.state.translate('title')} 36 | 37 | 38 | 39 | 40 | `; 41 | return html` 42 |
45 | ${title} ${this.account.render()} 46 |
47 | `; 48 | } 49 | } 50 | 51 | module.exports = Header; 52 | -------------------------------------------------------------------------------- /app/ui/home.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const { list } = require('../utils'); 3 | const archiveTile = require('./archiveTile'); 4 | const modal = require('./modal'); 5 | const intro = require('./intro'); 6 | 7 | module.exports = function(state, emit) { 8 | if (state.user.loginRequired && !state.user.loggedIn) { 9 | emit('signup-cta', 'required'); 10 | } 11 | const archives = state.storage.files 12 | .filter(archive => !archive.expired) 13 | .map(archive => archiveTile(state, emit, archive)); 14 | let left = ''; 15 | if (state.uploading) { 16 | left = archiveTile.uploading(state, emit); 17 | } else if (state.archive.numFiles > 0) { 18 | left = archiveTile.wip(state, emit); 19 | } else { 20 | left = archiveTile.empty(state, emit); 21 | } 22 | archives.reverse(); 23 | const right = 24 | archives.length === 0 25 | ? intro(state) 26 | : list(archives, 'p-2 h-full overflow-y-auto w-full', 'mb-4 w-full'); 27 | 28 | return html` 29 |
30 | ${state.modal && modal(state, emit)} 31 |
34 |
${left}
35 |
${right}
36 |
37 |
38 | `; 39 | }; 40 | -------------------------------------------------------------------------------- /app/ui/intro.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const assets = require('../../common/assets'); 3 | 4 | module.exports = function intro(state) { 5 | return html` 6 | 9 |
10 |

11 | ${state.translate('introTitle')} 12 |

13 |

14 | ${state.translate('introDescription')} 15 |

16 | 17 |
18 |
19 | `; 20 | }; 21 | -------------------------------------------------------------------------------- /app/ui/modal.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = function(state, emit) { 4 | return html` 5 | 8 |
11 |
12 | ${state.modal(state, emit, close)} 13 |
14 |
15 |
16 | `; 17 | 18 | function close(event) { 19 | if (event) { 20 | event.preventDefault(); 21 | event.stopPropagation(); 22 | } 23 | emit('closeModal'); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /app/ui/notFound.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const assets = require('../../common/assets'); 3 | const modal = require('./modal'); 4 | 5 | module.exports = function(state, emit) { 6 | const btnText = state.user.loggedIn ? 'okButton' : 'sendYourFilesLink'; 7 | return html` 8 |
9 | ${state.modal && modal(state, emit)} 10 |
13 |

14 | ${state.translate('expiredTitle')} 15 |

16 | 17 |

23 | ${state.translate('trySendDescription')} 24 |

25 |

26 | ${state.translate(btnText)} 29 |

30 |

31 | ${state.translate('reportFile')} 34 |

35 |
36 |
37 | `; 38 | }; 39 | -------------------------------------------------------------------------------- /app/ui/okDialog.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = function(message) { 4 | return function(state, emit, close) { 5 | return html` 6 | 7 |

8 | ${message} 9 |

10 | 17 |
18 | `; 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /app/ui/selectbox.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = function(selected, options, translate, changed, htmlId) { 4 | let x = selected; 5 | 6 | return html` 7 | 21 | `; 22 | 23 | function choose(event) { 24 | const target = event.target; 25 | const value = +target.value; 26 | 27 | if (x !== value) { 28 | x = value; 29 | changed(value); 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /app/ui/shareDialog.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | 3 | module.exports = function(name, url) { 4 | const dialog = function(state, emit, close) { 5 | return html` 6 | 9 |

10 | ${state.translate('notifyUploadEncryptDone')} 11 |

12 |

13 | ${state.translate('shareLinkDescription')}
14 | ${name} 15 |

16 | 23 | 30 | 37 |
38 | `; 39 | 40 | async function share(event) { 41 | event.stopPropagation(); 42 | try { 43 | await navigator.share({ 44 | title: state.translate('-send-brand'), 45 | text: state.translate('shareMessage', { name }), 46 | url 47 | }); 48 | } catch (e) { 49 | if (e.code === e.ABORT_ERR) { 50 | return; 51 | } 52 | console.error(e); 53 | } 54 | close(); 55 | } 56 | }; 57 | dialog.type = 'share'; 58 | return dialog; 59 | }; 60 | -------------------------------------------------------------------------------- /app/ui/unsupported.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const modal = require('./modal'); 3 | 4 | module.exports = function(state, emit) { 5 | let strings = {}; 6 | let why = ''; 7 | let url = ''; 8 | 9 | if (state.params.reason !== 'outdated') { 10 | strings = unsupportedStrings(state); 11 | why = html` 12 | 16 | ${state.translate('notSupportedLink')} 17 | 18 | `; 19 | url = 20 | 'https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com'; 21 | } else { 22 | strings = outdatedStrings(state); 23 | url = 'https://support.mozilla.org/kb/update-firefox-latest-version'; 24 | } 25 | 26 | return html` 27 |
28 | ${state.modal && modal(state, emit)} 29 |
32 |

${strings.header}

33 |

${strings.description}

34 | ${why} 35 | 36 | ${strings.button} 37 | 38 |
39 |
40 | `; 41 | }; 42 | 43 | function outdatedStrings(state) { 44 | return { 45 | header: state.translate('notSupportedHeader'), 46 | description: state.translate('notSupportedOutdatedDetail'), 47 | button: state.translate('updateFirefox') 48 | }; 49 | } 50 | 51 | function unsupportedStrings(state) { 52 | return { 53 | header: state.translate('notSupportedHeader'), 54 | description: state.translate('notSupportedDescription'), 55 | button: state.translate('downloadFirefox') 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/addfiles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/blue_file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /assets/close-16.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/completed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/copy-16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/dl.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /assets/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/feedback.svg: -------------------------------------------------------------------------------- 1 | Combined Shape -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/intro.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/lock.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /assets/master-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/notFound.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /assets/select-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/share-24.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /assets/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/wordmark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | last 2 chrome versions 2 | last 2 firefox versions 3 | last 2 safari versions 4 | last 2 edge versions 5 | edge 18 6 | firefox esr 7 | -------------------------------------------------------------------------------- /build/android_index_plugin.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const html = require('choo/html'); 3 | const NAME = 'AndroidIndexPlugin'; 4 | 5 | function chunkFileNames(compilation) { 6 | const names = {}; 7 | for (const chunk of compilation.chunks) { 8 | for (const file of chunk.files) { 9 | if (!/\.map$/.test(file)) { 10 | names[`${chunk.name}${path.extname(file)}`] = file; 11 | } 12 | } 13 | } 14 | return names; 15 | } 16 | class AndroidIndexPlugin { 17 | apply(compiler) { 18 | compiler.hooks.emit.tap(NAME, compilation => { 19 | const files = chunkFileNames(compilation); 20 | const page = html` 21 | 22 | 23 | Send 24 | 25 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ` 36 | .toString() 37 | .replace(/\n\s{6}/g, '\n'); 38 | compilation.assets['android.html'] = { 39 | source() { 40 | return page; 41 | }, 42 | size() { 43 | return page.length; 44 | } 45 | }; 46 | }); 47 | } 48 | } 49 | 50 | module.exports = AndroidIndexPlugin; 51 | -------------------------------------------------------------------------------- /build/readme.md: -------------------------------------------------------------------------------- 1 | # Custom Loaders 2 | 3 | ## Android Index Plugin 4 | 5 | Generates the `index.html` page for the native android client 6 | 7 | ## Version Plugin 8 | 9 | Creates a `version.json` file that gets exposed by the `/__version__` route from the `package.json` file and current git commit hash. 10 | 11 | # See Also 12 | 13 | - [docs/build.md](../docs/build.md) 14 | - [webpack.config.js](../webpack.config.js) -------------------------------------------------------------------------------- /build/version_plugin.js: -------------------------------------------------------------------------------- 1 | const gitRevSync = require('git-rev-sync'); 2 | const pkg = require('../package.json'); 3 | 4 | let commit = 'unknown'; 5 | 6 | try { 7 | commit = gitRevSync.short(); 8 | } catch (e) { 9 | console.warn('Error fetching current git commit: ' + e); 10 | } 11 | 12 | const version = JSON.stringify({ 13 | commit, 14 | source: pkg.homepage, 15 | version: process.env.CIRCLE_TAG || `v${pkg.version}` 16 | }); 17 | 18 | class VersionPlugin { 19 | apply(compiler) { 20 | compiler.hooks.emit.tap('VersionPlugin', compilation => { 21 | compilation.assets['version.json'] = { 22 | source() { 23 | return version; 24 | }, 25 | size() { 26 | return version.length; 27 | } 28 | }; 29 | }); 30 | } 31 | } 32 | 33 | module.exports = VersionPlugin; 34 | -------------------------------------------------------------------------------- /common/assets.js: -------------------------------------------------------------------------------- 1 | const genmap = require('./generate_asset_map'); 2 | const isServer = typeof genmap === 'function'; 3 | let prefix = ''; 4 | let manifest = {}; 5 | try { 6 | //eslint-disable-next-line node/no-missing-require 7 | manifest = require('../dist/manifest.json'); 8 | } catch (e) { 9 | // use middleware 10 | } 11 | 12 | const assets = isServer ? manifest : genmap; 13 | 14 | function getAsset(name) { 15 | return prefix + assets[name]; 16 | } 17 | 18 | function setPrefix(name) { 19 | prefix = name; 20 | } 21 | 22 | function getMatches(match) { 23 | return Object.keys(assets) 24 | .filter(k => match.test(k)) 25 | .map(getAsset); 26 | } 27 | 28 | const instance = { 29 | setPrefix: setPrefix, 30 | get: getAsset, 31 | match: getMatches, 32 | setMiddleware: function(middleware) { 33 | function getManifest() { 34 | return JSON.parse( 35 | middleware.fileSystem.readFileSync( 36 | middleware.getFilenameFromUrl('/manifest.json') 37 | ) 38 | ); 39 | } 40 | if (middleware) { 41 | instance.get = function getAssetWithMiddleware(name) { 42 | const m = getManifest(); 43 | return prefix + m[name]; 44 | }; 45 | instance.match = function matchAssetWithMiddleware(match) { 46 | const m = getManifest(); 47 | return Object.keys(m) 48 | .filter(k => match.test(k)) 49 | .map(k => prefix + m[k]); 50 | }; 51 | } 52 | } 53 | }; 54 | 55 | module.exports = instance; 56 | -------------------------------------------------------------------------------- /common/generate_asset_map.js: -------------------------------------------------------------------------------- 1 | /* 2 | This code is included by both the server and frontend via 3 | common/assets.js 4 | 5 | When included from the server the export will be the function. 6 | 7 | When included from the frontend (via webpack) the export will 8 | be an object mapping file names to hashed file names. Example: 9 | "send_logo.svg": "send_logo.5fcfdf0e.svg" 10 | */ 11 | 12 | const fs = require('fs'); 13 | const path = require('path'); 14 | 15 | function kv(f) { 16 | return `"${f}": require('../assets/${f}')`; 17 | } 18 | 19 | module.exports = function() { 20 | const files = fs.readdirSync(path.join(__dirname, '..', 'assets')); 21 | const code = `module.exports = { 22 | ${files.map(kv).join(',\n')} 23 | };`; 24 | return { 25 | code, 26 | dependencies: files.map(f => require.resolve('../assets/' + f)), 27 | cacheable: true 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /common/readme.md: -------------------------------------------------------------------------------- 1 | # Common Code 2 | 3 | This directory contains code loaded by both the frontend `app` and backend `server`. The code here can be challenging to understand at first because the contexts for the two (three counting the dev server) environments that include them are quite different, but the purpose of these modules are quite simple, to provide mappings from the source assets (`copy-16.png`) to the concrete production assets (`copy-16.db66e0bf.svg`). 4 | 5 | ## Generate Asset Map 6 | 7 | This loader enumerates all the files in `assets/` so that `common/assets.js` can provide mappings from the source filename to the hashed filename used on the site. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | build: . 5 | links: 6 | - redis 7 | ports: 8 | - "1443:1443" 9 | environment: 10 | - REDIS_HOST=redis 11 | redis: 12 | image: redis:alpine 13 | selenium-firefox: 14 | image: b4handjr/selenium-firefox 15 | ports: 16 | - "${VNC_PORT:-5900}:5900" 17 | shm_size: 2g 18 | volumes: 19 | - .:/code 20 | -------------------------------------------------------------------------------- /docs/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # flod as main contact for string changes 2 | public/locales/en-US/*.ftl @flodolo 3 | -------------------------------------------------------------------------------- /docs/build.md: -------------------------------------------------------------------------------- 1 | Send has two build configurations, development and production. Both can be run via `npm` scripts, `npm start` for development and `npm run build` for production. Webpack is our only build tool and all configuration lives in [webpack.config.js](../webpack.config.js). 2 | 3 | # Development 4 | 5 | `npm start` launches a `webpack-dev-server` on port 8080 that compiles the assets and watches files for changes. It also serves the backend API and frontend unit tests via the `server/bin/dev.js` entrypoint. The frontend tests can be run in the browser by navigating to http://localhost:8080/test and will rerun automatically as the watched files are saved with changes. 6 | 7 | # Production 8 | 9 | `npm run build` compiles the assets and writes the files to the `dist/` directory. `npm run prod` launches an Express server on port 1443 that serves the backend API and frontend static assets from `dist/` via the `server/bin/prod.js` entrypoint. 10 | 11 | # Notable differences 12 | 13 | - Development compiles assets in memory, so no `dist/` directory is generated 14 | - Development does not enable CSP headers 15 | - Development frontend source is instrumented for code coverage 16 | - Only development includes sourcemaps 17 | - Only development exposes the `/test` route 18 | - Production sets Cache-Control immutable headers on the hashed static assets 19 | 20 | # Custom Loaders 21 | 22 | The `build/` directory contains custom webpack loaders specific to Send. See [build/readme.md](../build/readme.md) for details on each loader. -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | ## Requirements 2 | This document describes how to do a full deployment of Firefox Send on your own Linux server. You will need: 3 | 4 | * A working (and ideally somewhat recent) installation of NodeJS and NPM 5 | * GIT 6 | * An Apache webserver 7 | * Optionally telnet, to be able to quickly check your installation 8 | 9 | For Debian/Ubuntu systems this probably just means something like this: 10 | 11 | * apt install git apache2 nodejs npm telnet 12 | 13 | ## Building 14 | * We assume an already configured virtual-host on your webserver with an existing empty htdocs folder 15 | * First, remove that htdocs folder - we will replace it with Firefox Send's version now 16 | * git clone https://github.com/mozilla/send.git htdocs 17 | * Make now sure you are NOT root but rather the user your webserver is serving files under (e.g. "su www-data" or whoever the owner of your htdocs folder is) 18 | * npm install 19 | * npm run build 20 | 21 | ## Running 22 | To have a permanently running version of Firefox Send as a background process: 23 | 24 | * Create a file "run.sh" with: 25 | ``` 26 | #!/bin/bash 27 | nohup su www-data -c "npm run prod" 2>/dev/null & 28 | ``` 29 | * chmod +x run.sh 30 | * ./run.sh 31 | 32 | Now the Firefox Send backend should be running on port 1443. You can check with: 33 | * telnet localhost 1443 34 | 35 | ## Reverse Proxy 36 | Of course, we don't want to expose the service on port 1443. Instead we want our normal webserver to forward all requests to Firefox send ("Reverse proxy"). 37 | 38 | # Apache webserver 39 | 40 | * a2enmod proxy 41 | * a2enmod proxy_http 42 | * a2enmod proxy_wstunnel 43 | 44 | In your Apache virtual host configuration file, insert this: 45 | 46 | ``` 47 | # Enable rewrite engine 48 | RewriteEngine on 49 | 50 | # Make sure the original domain name is forwarded to Send 51 | # Otherwise the generated URLs will be wrong 52 | ProxyPreserveHost on 53 | 54 | # Make sure the generated URL is https:// 55 | RequestHeader set X-Forwarded-Proto https 56 | 57 | # If it's a normal file (e.g. PNG, CSS) just return it 58 | RewriteCond %{REQUEST_FILENAME} -f 59 | RewriteRule .* - [L] 60 | 61 | # If it's a websocket connection, redirect it to a Send WS connection 62 | RewriteCond %{HTTP:Upgrade} =websocket [NC] 63 | RewriteRule /(.*) ws://127.0.0.1:1443/$1 [P,L] 64 | 65 | # Otherwise redirect it to a normal HTTP connection 66 | RewriteRule ^/(.*)$ http://127.0.0.1:1443/$1 [P,QSA] 67 | ProxyPassReverse "/" "http://127.0.0.1:1443" 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Run `docker build -t send:latest .` to create an image or `docker-compose up` to run a full testable stack. *We don't recommend using docker-compose for production.* 4 | 5 | ## Environment variables: 6 | 7 | | Name | Description 8 | |------------------|-------------| 9 | | `PORT` | Port the server will listen on (defaults to 1443). 10 | | `S3_BUCKET` | The S3 bucket name. 11 | | `REDIS_HOST` | Host name of the Redis server. 12 | | `SENTRY_CLIENT` | Sentry Client ID 13 | | `SENTRY_DSN` | Sentry DSN 14 | | `MAX_FILE_SIZE` | in bytes (defaults to 2147483648) 15 | | `NODE_ENV` | "production" 16 | | `BASE_URL` | The HTTPS URL where traffic will be served (e.g. `https://send.firefox.com`) 17 | 18 | ## Example: 19 | 20 | ```sh 21 | $ docker run --net=host -e 'NODE_ENV=production' \ 22 | -e 'S3_BUCKET=testpilot-p2p-dev' \ 23 | -e 'REDIS_HOST=dyf9s2r4vo3.bolxr4.0001.usw2.cache.amazonaws.com' \ 24 | -e 'SENTRY_CLIENT=https://51e23d7263e348a7a3b90a5357c61cb2@sentry.prod.mozaws.net/168' \ 25 | -e 'SENTRY_DSN=https://51e23d7263e348a7a3b90a5357c61cb2:65e23d7263e348a7a3b90a5357c61c44@sentry.prod.mozaws.net/168' \ 26 | -e 'BASE_URL=https://send.firefox.com' \ 27 | mozilla/send:latest 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/encryption.md: -------------------------------------------------------------------------------- 1 | # File Encryption 2 | 3 | Send use 128-bit AES-GCM encryption via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) to encrypt files in the browser before uploading them to the server. The code is in [app/keychain.js](../app/keychain.js). 4 | 5 | ## Steps 6 | 7 | ### Uploading 8 | 9 | 1. A new secret key is generated with `crypto.getRandomValues` 10 | 2. The secret key is used to derive more keys via HKDF SHA-256 11 | - a series of encryption keys for the file, via [ECE](https://tools.ietf.org/html/rfc8188) (AES-GCM) 12 | - an encryption key for the file metadata (AES-GCM) 13 | - a signing key for request authentication (HMAC SHA-256) 14 | 3. The file and metadata are encrypted with their corresponding keys 15 | 4. The encrypted data and signing key are uploaded to the server 16 | 5. An owner token and the share url are returned by the server and stored in local storage 17 | 6. The secret key is appended to the share url as a [#fragment](https://en.wikipedia.org/wiki/Fragment_identifier) and presented to the UI 18 | 19 | ### Downloading 20 | 21 | 1. The browser loads the share url page, which includes an authentication nonce 22 | 2. The browser imports the secret key from the url fragment 23 | 3. The same 3 keys as above are derived 24 | 4. The browser signs the nonce with its signing key and requests the metadata 25 | 5. The encrypted metadata is decrypted and presented on the page 26 | 6. The browser makes another authenticated request to download the encrypted file 27 | 7. The browser downloads and decrypts the file 28 | 8. The file prompts the save dialog or automatically saves depending on the browser settings 29 | 30 | ### Passwords 31 | 32 | A password may optionally be set to authenticate the download request. When a password is set the following steps occur. 33 | 34 | #### Sender 35 | 36 | 1. The original signing key derived from the secret key is discarded 37 | 2. A new signing key is generated via PBKDF2 from the user entered password and the full share url (including secret key fragment) 38 | 3. The new key is sent to the server, authenticated by the owner token 39 | 4. The server stores the new key and marks the record as needing a password 40 | 41 | #### Downloader 42 | 43 | 1. The browser loads the share url page, which includes an authentication nonce and indicator that the file requires a password 44 | 2. The user is prompted for the password and the signing key is derived 45 | 3. The browser requests the metadata using the key to sign the nonce 46 | 4. If the password was correct the metadata is returned, otherwise a 401 47 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | ## How big of a file can I transfer with Firefox Send? 2 | 3 | There is a 2.5GB file size limit built in to Send(1GB for non-signed in users), however, in practice you may 4 | be unable to send files that large. Send encrypts and decrypts the files in 5 | the browser which is great for security but will tax your system resources. In 6 | particular you can expect to see your memory usage go up by at least the size 7 | of the file when the transfer is processing. You can see [the results of some 8 | testing](https://github.com/mozilla/send/issues/170#issuecomment-314107793). 9 | For the most reliable operation on common computers, it’s probably best to stay 10 | under a few hundred megabytes. 11 | 12 | ## Why is my browser not supported? 13 | 14 | We’re using the [Web Cryptography JavaScript API with the AES-GCM 15 | algorithm](https://www.w3.org/TR/WebCryptoAPI/#aes-gcm) for our encryption. 16 | Many browsers support this standard and should work fine, but some have not 17 | implemented it yet (mobile browsers lag behind on this, in 18 | particular). 19 | 20 | ## Why does Firefox Send require JavaScript? 21 | 22 | Firefox Send uses JavaScript to: 23 | 24 | - Encrypt and decrypt files locally on the client instead of the server. 25 | - Render the user interface. 26 | - Manage translations on the website into [various different languages](https://github.com/mozilla/send#localization). 27 | - Collect data to help us improve Send in accordance with our [Terms & Privacy](https://send.firefox.com/legal). 28 | 29 | Since Send is an open source project, you can see all of the cool ways we use JavaScript by [examining our code](https://github.com/mozilla/send/). 30 | 31 | ## How long are files available for? 32 | 33 | Files are available to be downloaded for 24 hours, after which they are removed 34 | from the server. They are also removed immediately once the download limit is reached. 35 | 36 | ## Can a file be downloaded more than once? 37 | 38 | Yes, once a file is submitted to Send you can select the download limit. 39 | 40 | 41 | *Disclaimer: Send is an experiment and under active development. The answers 42 | here may change as we get feedback from you and the project matures.* 43 | -------------------------------------------------------------------------------- /docs/localization.md: -------------------------------------------------------------------------------- 1 | # Localization 2 | 3 | Send is localized in over 50 languages. We use the [fluent](http://projectfluent.org/) library and store our translations in [FTL](http://projectfluent.org/fluent/guide/) files in `public/locales/`. `en-US` is our base language, and other languages are managed by [pontoon](https://pontoon.mozilla.org/projects/test-pilot-firefox-send/). 4 | 5 | ## Process 6 | 7 | Strings are added or removed from [public/locales/en-US/send.ftl] as needed. Strings **MUST NOT** be *changed* after they've been commited and pushed to master. Changing a string requires creating a new ID with a new name (preferably descriptive instead of incremented) and deletion of the obsolete ID. It's often useful to add a comment above the string with info about how and where the string is used. 8 | 9 | Once new strings are commited to master they are available for translators in Pontoon. All languages other than `en-US` should be edited via Pontoon. Translations get automatically commited to the github master branch. 10 | 11 | ### Activation 12 | 13 | The development environment includes all locales in `public/locales` via the `L10N_DEV` environment variable. Production uses `package.json` as the list of locales to use. Once a locale has enough string coverage it should be added to `package.json`. 14 | 15 | ## Code 16 | 17 | In `app/` we use the `state.translate()` function to translate strings to the best matching language base on the user's `Accept-Language` header. It's a wrapper around fluent's [FluentBundle.format](http://projectfluent.org/fluent.js/fluent/FluentBundle.html). It works the same for both server and client side rendering. 18 | 19 | ### Examples 20 | 21 | ```js 22 | // simple string 23 | const finishedString = state.translate('downloadFinish') 24 | // with parameters 25 | const progressString = state.translate('downloadingPageProgress', { 26 | filename: state.fileInfo.name, 27 | size: bytes(state.fileInfo.size) 28 | }) 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/notes/streams.md: -------------------------------------------------------------------------------- 1 | # Web Streams 2 | 3 | - API 4 | - https://developer.mozilla.org/en-US/docs/Web/API/Streams_API 5 | - Reference Implementation 6 | - https://github.com/whatwg/streams/tree/master/reference-implementation 7 | - Examples 8 | - https://github.com/mdn/dom-examples/tree/master/streams 9 | - Polyfill 10 | - https://github.com/MattiasBuelens/web-streams-polyfill 11 | 12 | # Encrypted Content Encoding 13 | 14 | - Spec 15 | - https://trac.tools.ietf.org/html/rfc8188 16 | - node.js implementation 17 | - https://github.com/web-push-libs/encrypted-content-encoding/tree/master/nodejs 18 | 19 | # Other APIs 20 | 21 | - Blobs 22 | - https://developer.mozilla.org/en-US/docs/Web/API/Blob 23 | - ArrayBuffers, etc 24 | - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer 25 | - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array 26 | - FileReader 27 | - https://developer.mozilla.org/en-US/docs/Web/API/FileReader 28 | 29 | # Other 30 | 31 | - node.js Buffer browser library 32 | - https://github.com/feross/buffer 33 | - StreamSaver 34 | - https://github.com/jimmywarting/StreamSaver.js 35 | -------------------------------------------------------------------------------- /docs/takedowns.md: -------------------------------------------------------------------------------- 1 | ## Take-down process 2 | 3 | In cases of a DMCA notice, or other abuse yet to be determined, a file has to be removed from the service. 4 | 5 | Files can be delisted and made inaccessible by removing their record from Redis. 6 | 7 | Send share links contain the `id` of the file, for example `https://send.firefox.com/download/3d9d2bb9a1` 8 | 9 | From a host with access to the Redis server run a `DEL` command with the file id. 10 | 11 | For example: 12 | 13 | ```sh 14 | redis-cli DEL 3d9d2bb9a1 15 | ``` 16 | 17 | Other redis-cli parameters like `-h` may also be required. See [redis-cli docs](https://redis.io/topics/rediscli) for more info. 18 | 19 | The encrypted file resides on S3 as the same `id` under the bucket that the app was configured with as `S3_BUCKET`. The file can be managed if it has not already expired with the [AWS cli](https://docs.aws.amazon.com/cli/latest/reference/s3/index.html) or AWS web console. -------------------------------------------------------------------------------- /ios/generate-bundle.js: -------------------------------------------------------------------------------- 1 | const child_process = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | child_process.execSync('npm run build'); 6 | 7 | const prefix = path.join('..', 'dist'); 8 | const json_string = fs.readFileSync(path.join(prefix, 'manifest.json')); 9 | const manifest = JSON.parse(json_string); 10 | 11 | const ios_filename = manifest['ios.js']; 12 | fs.writeFileSync( 13 | 'send-ios/assets/ios.js', 14 | fs.readFileSync(`${prefix}${ios_filename}`) 15 | ); 16 | 17 | const vendor_filename = manifest['vendor.js']; 18 | fs.writeFileSync( 19 | 'send-ios/assets/vendor.js', 20 | fs.readFileSync(`${prefix}${vendor_filename}`) 21 | ); 22 | -------------------------------------------------------------------------------- /ios/send-ios-action-extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | send-ios-action-extension 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | XPC! 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | NSExtension 24 | 25 | NSExtensionAttributes 26 | 27 | NSExtensionActivationRule 28 | SUBQUERY ( 29 | extensionItems, 30 | $extensionItem, 31 | SUBQUERY ( 32 | $extensionItem.attachments, 33 | $attachment, 34 | ( 35 | ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf" 36 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" 37 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" 38 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" 39 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" 40 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" 41 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" 42 | || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" 43 | ) 44 | ).@count == 1 45 | ).@count == 1 46 | 47 | NSExtensionMainStoryboard 48 | MainInterface 49 | NSExtensionPointIdentifier 50 | com.apple.ui-services 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /ios/send-ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/send-ios.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/send-ios.xcodeproj/project.xcworkspace/xcuserdata/donovan.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/ios/send-ios.xcodeproj/project.xcworkspace/xcuserdata/donovan.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /ios/send-ios.xcodeproj/xcuserdata/donovan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /ios/send-ios.xcodeproj/xcuserdata/donovan.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | send-ios-action-extension.xcscheme 8 | 9 | orderHint 10 | 1 11 | 12 | send-ios.xcscheme 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /ios/send-ios/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // send-ios 4 | // 5 | // Created by Donovan Preston on 7/19/18. 6 | // 7 | 8 | import UIKit 9 | 10 | @UIApplicationMain 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 17 | // Override point for customization after application launch. 18 | return true 19 | } 20 | 21 | func applicationWillResignActive(_ application: UIApplication) { 22 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 23 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 24 | } 25 | 26 | func applicationDidEnterBackground(_ application: UIApplication) { 27 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 28 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 29 | } 30 | 31 | func applicationWillEnterForeground(_ application: UIApplication) { 32 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 33 | } 34 | 35 | func applicationDidBecomeActive(_ application: UIApplication) { 36 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 37 | } 38 | 39 | func applicationWillTerminate(_ application: UIApplication) { 40 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 41 | } 42 | 43 | 44 | } 45 | 46 | -------------------------------------------------------------------------------- /ios/send-ios/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /ios/send-ios/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ios/send-ios/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ios/send-ios/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ios/send-ios/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // send-ios 4 | // 5 | // Created by Donovan Preston on 7/19/18. 6 | // 7 | 8 | import UIKit 9 | import WebKit 10 | 11 | class ViewController: UIViewController, WKScriptMessageHandler { 12 | @IBOutlet var webView: WKWebView! 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | self.webView.frame = self.view.bounds 17 | self.webView?.configuration.userContentController.add(self, name: "loaded") 18 | self.webView?.configuration.userContentController.add(self, name: "copy") 19 | if let url = Bundle.main.url( 20 | forResource: "index", 21 | withExtension: "html", 22 | subdirectory: "assets") { 23 | webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) 24 | } 25 | } 26 | 27 | public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 28 | print("Message received: \(message.name) with body: \(message.body)") 29 | UIPasteboard.general.string = "\(message.body)" 30 | } 31 | 32 | override func didReceiveMemoryWarning() { 33 | super.didReceiveMemoryWarning() 34 | // Dispose of any resources that can be recreated. 35 | } 36 | 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /ios/send-ios/assets/background_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/ios/send-ios/assets/background_1.jpg -------------------------------------------------------------------------------- /ios/send-ios/assets/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: url('background_1.jpg'); 3 | display: flex; 4 | flex-direction: row; 5 | flex: auto; 6 | justify-content: center; 7 | align-items: center; 8 | padding: 0 20px; 9 | box-sizing: border-box; 10 | position: fixed; 11 | top: 0; 12 | left: 0; 13 | right: 0; 14 | bottom: 0; 15 | } 16 | 17 | #striped { 18 | background-image: repeating-linear-gradient( 19 | 45deg, 20 | white, 21 | white 5px, 22 | #ea000e 5px, 23 | #ea000e 25px, 24 | white 25px, 25 | white 30px, 26 | #0083ff 30px, 27 | #0083ff 50px 28 | ); 29 | height: 350px; 30 | width: 480px; 31 | } 32 | 33 | #white { 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | flex-direction: column; 38 | height: 100%; 39 | background-color: white; 40 | margin: 0 10px; 41 | padding: 1px 10px 0 10px; 42 | } 43 | 44 | #label { 45 | background: #0297f8; 46 | border: 1px solid #0297f8; 47 | color: white; 48 | font-size: 24px; 49 | font-weight: 500; 50 | height: 60px; 51 | width: 200px; 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | } 56 | 57 | #input { 58 | display: none; 59 | } 60 | 61 | #url { 62 | flex: 1; 63 | width: 100%; 64 | height: 32px; 65 | font-size: 24px; 66 | margin-top: 1em; 67 | } 68 | 69 | .button { 70 | flex: 1; 71 | display: block; 72 | background: #0297f8; 73 | border: 1px solid #0297f8; 74 | color: white; 75 | font-size: 24px; 76 | font-weight: 500; 77 | width: 95%; 78 | height: 32px; 79 | margin-top: 1em; 80 | } 81 | 82 | #send-another { 83 | margin-bottom: 1em; 84 | } 85 | -------------------------------------------------------------------------------- /ios/send-ios/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Send 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /ios/send-ios/help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | HELLO WORLD 4 | 5 | 6 | -------------------------------------------------------------------------------- /l10n.toml: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | basepath = "." 6 | 7 | [env] 8 | l = "{l10n_base}/public/locales/{locale}/" 9 | 10 | [[paths]] 11 | reference = "public/locales/en-US/**" 12 | l10n = "{l}**" 13 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | class TailwindExtractor { 2 | static extract(content) { 3 | return content.match(/[A-Za-z0-9-_:/]+/g) || []; 4 | } 5 | } 6 | 7 | const options = { 8 | plugins: [ 9 | require('tailwindcss')('./tailwind.config.js'), 10 | require('postcss-preset-env') 11 | ] 12 | }; 13 | 14 | if (process.env.NODE_ENV === 'development') { 15 | options.map = { inline: true }; 16 | } else { 17 | options.plugins.push( 18 | require('@fullhuman/postcss-purgecss')({ 19 | content: [ 20 | './app/*.js', 21 | './app/ui/*.js', 22 | './android/*.js', 23 | './android/pages/*.js' 24 | ], 25 | extractors: [ 26 | { 27 | extractor: TailwindExtractor, 28 | extensions: ['js'] 29 | } 30 | ] 31 | }) 32 | ); 33 | options.plugins.push( 34 | require('cssnano')({ 35 | preset: 'default' 36 | }) 37 | ); 38 | } 39 | 40 | module.exports = options; 41 | -------------------------------------------------------------------------------- /public/Inter-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Black.woff -------------------------------------------------------------------------------- /public/Inter-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Black.woff2 -------------------------------------------------------------------------------- /public/Inter-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-BlackItalic.woff -------------------------------------------------------------------------------- /public/Inter-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-BlackItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Bold.woff -------------------------------------------------------------------------------- /public/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Bold.woff2 -------------------------------------------------------------------------------- /public/Inter-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-BoldItalic.woff -------------------------------------------------------------------------------- /public/Inter-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-BoldItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraBold.woff -------------------------------------------------------------------------------- /public/Inter-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraBold.woff2 -------------------------------------------------------------------------------- /public/Inter-ExtraBoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraBoldItalic.woff -------------------------------------------------------------------------------- /public/Inter-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-ExtraLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraLight.woff -------------------------------------------------------------------------------- /public/Inter-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraLight.woff2 -------------------------------------------------------------------------------- /public/Inter-ExtraLightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraLightItalic.woff -------------------------------------------------------------------------------- /public/Inter-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Italic.woff -------------------------------------------------------------------------------- /public/Inter-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Italic.woff2 -------------------------------------------------------------------------------- /public/Inter-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Light.woff -------------------------------------------------------------------------------- /public/Inter-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Light.woff2 -------------------------------------------------------------------------------- /public/Inter-LightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-LightItalic.woff -------------------------------------------------------------------------------- /public/Inter-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-LightItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Medium.woff -------------------------------------------------------------------------------- /public/Inter-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Medium.woff2 -------------------------------------------------------------------------------- /public/Inter-MediumItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-MediumItalic.woff -------------------------------------------------------------------------------- /public/Inter-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-MediumItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Regular.woff -------------------------------------------------------------------------------- /public/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Regular.woff2 -------------------------------------------------------------------------------- /public/Inter-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-SemiBold.woff -------------------------------------------------------------------------------- /public/Inter-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-SemiBold.woff2 -------------------------------------------------------------------------------- /public/Inter-SemiBoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-SemiBoldItalic.woff -------------------------------------------------------------------------------- /public/Inter-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Thin.woff -------------------------------------------------------------------------------- /public/Inter-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-Thin.woff2 -------------------------------------------------------------------------------- /public/Inter-ThinItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ThinItalic.woff -------------------------------------------------------------------------------- /public/Inter-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-ThinItalic.woff2 -------------------------------------------------------------------------------- /public/Inter-italic.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-italic.var.woff2 -------------------------------------------------------------------------------- /public/Inter-upright.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter-upright.var.woff2 -------------------------------------------------------------------------------- /public/Inter.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/Inter.var.woff2 -------------------------------------------------------------------------------- /public/contribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firefox-send", 3 | "description": "File Sharing Experiment", 4 | "repository": { 5 | "url": "https://github.com/mozilla/send/", 6 | "license": "MPL-2.0" 7 | }, 8 | "participate": { 9 | "home": "https://github.com/mozilla/send/blob/master/README.md", 10 | "docs": "https://github.com/mozilla/send/blob/master/README.md" 11 | }, 12 | "bugs": { 13 | "list": "https://github.com/mozilla/send/issues", 14 | "report": "https://github.com/mozilla/send/issues/new" 15 | }, 16 | "urls": { 17 | "prod": "https://send.firefox.com/", 18 | "stage": "https://stage.send.nonprod.cloudops.mozgcp.net/", 19 | "dev": "https://send2.dev.lcip.org/" 20 | }, 21 | "keywords": [ 22 | "JavaScript", 23 | "jQuery", 24 | "Node", 25 | "Redis" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/send/ade10e496c064d3b29191dd33b1066bf99607d74/public/favicon.ico -------------------------------------------------------------------------------- /public/locales/ixl/send.ftl: -------------------------------------------------------------------------------- 1 | # Send is a brand name and should not be localized. 2 | title = Send 3 | siteFeedback = Aq'a yol sti' 4 | importingFile = Eq'otzan 5 | encryptingFile = La muj isik'lele 6 | decryptingFile = Ni jaj ve't isik'lele' 7 | downloadCount = 8 | { $num -> 9 | [one] Eq'omal ku'tzan 10 | *[other] { $num } Eq'omalaj ku'tzan 11 | } 12 | timespanHours = 13 | { $num -> 14 | [one] 1 Ch'ich' 15 | *[other] { $num } Nimalaj ch'ich' 16 | } 17 | copiedUrl = Eesamal ivatz! 18 | unlockInputPlaceholder = Kach'ub'al 19 | unlockButtonLabel = Eesa ikach'ub'al 20 | downloadButtonLabel = Eq'o ku'tzan 21 | downloadFinish = Eq'o ku'tzan kaajayil 22 | fileSizeProgress = ({ $partialSize }tetz{ $totalSize }) 23 | sendYourFilesLink = B'anb'e ve't u Send 24 | errorPageHeader = At ma'l kam valexh kat eli! 25 | notSupportedHeader = U chukb'al aq'one' ye' ni toleb'e'. 26 | notSupportedLink = Kam q'ii uve' ye' kuxh ni toleb' u chukb'al vaq'one'? 27 | updateFirefox = Tz'ajsa tatine' Firefox 28 | deletePopupCancel = Ya'samal 29 | deleteButtonHover = Sojsa 30 | footerLinkPrivacy = Tetz kuxhtu' 31 | footerLinkCookies = Cookies 32 | # A short representation of a countdown timer containing the number of hours and minutes remaining as digits, example "13h 47m" 33 | expiresHoursMinutes = { $hours }h { $minutes }m 34 | # A short representation of a countdown timer containing the number of minutes remaining as digits, example "56m" 35 | expiresMinutes = { $minutes }m 36 | 37 | ## Send version 2 strings 38 | 39 | # Send, Send, Firefox, Mozilla are proper names and should not be localized 40 | -send-brand = Send 41 | -send-short-brand = Aq'b'en 42 | -firefox = Firefox 43 | -mozilla = Mozilla 44 | # byte abbreviation 45 | bytes = B 46 | # kibibyte abbreviation 47 | kb = KB 48 | # mebibyte abbreviation 49 | mb = MB 50 | # gibibyte abbreviation 51 | gb = GB 52 | # localized number and byte abbreviation. example "2.5MB" 53 | fileSize = { $num }{ $units } 54 | # A short representation of a countdown timer containing the number of days, hours, and minutes remaining as digits, example "2d 11h 56m" 55 | expiresDaysHoursMinutes = { $days }d { $hours }h { $minutes }m 56 | emailPlaceholder = Aq'ku' a correo 57 | shareLinkButton = La jatxb'en u vaa' 58 | learnMore = Ootzi ka'te. 59 | -------------------------------------------------------------------------------- /public/locales/lus/send.ftl: -------------------------------------------------------------------------------- 1 | encryptingFile = Encrypting... 2 | decryptingFile = Decrypting 3 | 4 | ## Send version 2 strings 5 | 6 | -------------------------------------------------------------------------------- /public/locales/pai/send.ftl: -------------------------------------------------------------------------------- 1 | siteFeedback = Tkweek uk kabyuwuha 2 | 3 | ## Send version 2 strings 4 | 5 | -------------------------------------------------------------------------------- /public/locales/sn/send.ftl: -------------------------------------------------------------------------------- 1 | # Send is a brand name and should not be localized. 2 | title = Send 3 | siteFeedback = Zvirikutaurwa 4 | importingFile = Kutora faira 5 | encryptingFile = Kuinikiriputa 6 | enableJavascript = Ndinokumbira mubvumidze JavaScript moedza zvekare 7 | # A short representation of a countdown timer containing the number of hours and minutes remaining as digits, example "13h 47m" 8 | expiresHoursMinutes = { $hours }maawa { $minutes }mineti 9 | # A short representation of a countdown timer containing the number of minutes remaining as digits, example "56m" 10 | expiresMinutes = { $minutes }mineti 11 | # A short status message shown when the user enters a long password 12 | maxPasswordLength = Pasiwedhi haipfuuri mavara:{ $length } 13 | # A short status message shown when there was an error setting the password 14 | passwordSetError = Pasiwedhi haina kuita 15 | 16 | ## Send version 2 strings 17 | 18 | -------------------------------------------------------------------------------- /public/locales/yua/send.ftl: -------------------------------------------------------------------------------- 1 | # Send is a brand name and should not be localized. 2 | title = Send 3 | # A short representation of a countdown timer containing the number of minutes remaining as digits, example "56m" 4 | expiresMinutes = { $minutes }m 5 | 6 | ## Send version 2 strings 7 | 8 | -send-short-brand = Send 9 | -firefox = Firefox 10 | -mozilla = Mozilla 11 | # byte abbreviation 12 | bytes = B 13 | # kibibyte abbreviation 14 | kb = KB 15 | # mebibyte abbreviation 16 | mb = MB 17 | # gibibyte abbreviation 18 | gb = GB 19 | # localized number and byte abbreviation. example "2.5MB" 20 | fileSize = { $num }{ $units } 21 | -------------------------------------------------------------------------------- /scripts/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | node/shebang: off 3 | security/detect-child-process: off 4 | 5 | no-console: off 6 | no-process-exit: off 7 | -------------------------------------------------------------------------------- /scripts/bin/run-integration-test-circleci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | GECKODRIVER_URL=$( 5 | curl -s 'https://api.github.com/repos/mozilla/geckodriver/releases/latest' | 6 | python -c "import sys, json; r = json.load(sys.stdin); print([a for a in r['assets'] if 'linux64' in a['name']][0]['browser_download_url']);" 7 | ); 8 | 9 | 10 | curl -L -o geckodriver.tar.gz $GECKODRIVER_URL 11 | gunzip -c geckodriver.tar.gz | tar xopf - 12 | chmod +x geckodriver 13 | sudo mv geckodriver /bin 14 | geckodriver --version 15 | # Install pip 16 | sudo apt-get install python-pip 17 | sudo pip install --upgrade pip 18 | 19 | sudo pip install mozdownload mozinstall==1.15 20 | 21 | mkdir -p ~/project/firefox-downloads/ 22 | find ~/project/firefox-downloads/ -type f -mtime +90 -delete 23 | mozdownload --version latest --type daily --destination ~/project/firefox-downloads/firefox_nightly/ 24 | 25 | export PATH=~/project/firefox:$PATH 26 | mozinstall $(ls -t firefox-downloads/firefox_nightly/*.tar.bz2 | head -1) 27 | firefox --version 28 | npm run circleci-test-integration -------------------------------------------------------------------------------- /scripts/get-prod-locales.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const cp = require('child_process'); 4 | const { promisify } = require('util'); 5 | const fs = require('fs'); 6 | const pkg = require('../package.json'); 7 | 8 | const availableLanguages = pkg.availableLanguages.sort(); 9 | const exec = promisify(cp.exec); 10 | 11 | const arrayDiff = (current, package) => 12 | current.filter(locale => !package.includes(locale)); 13 | 14 | const cmd = 'compare-locales l10n.toml . `ls public/locales` --data=json'; 15 | 16 | exec(cmd) 17 | .then(({ stdout }) => JSON.parse(stdout)) 18 | .then(({ summary }) => { 19 | const locales = Object.keys(summary) 20 | .filter(locale => { 21 | const loc = summary[locale]; 22 | const hasMissing = Object.prototype.hasOwnProperty.call(loc, 'missing'); 23 | const hasErrors = Object.prototype.hasOwnProperty.call(loc, 'errors'); 24 | return !hasMissing && !hasErrors; 25 | }) 26 | .sort(); 27 | 28 | if (locales.join(',') !== availableLanguages.join(',')) { 29 | const missingLanguages = arrayDiff(locales, availableLanguages); 30 | 31 | console.log('current 100%:', JSON.stringify(locales)); 32 | console.log('package.json:', JSON.stringify(availableLanguages)); 33 | console.log('missing prod:', JSON.stringify(missingLanguages)); 34 | 35 | if (process.argv.includes('--write')) { 36 | const pkgPath = require.resolve('../package.json'); 37 | pkg.availableLanguages = locales; 38 | const str = JSON.stringify(pkg, null, 2) + '\n'; 39 | console.log('Updating /package.json availableLanguages'); 40 | fs.writeFileSync(pkgPath, str, 'utf-8'); 41 | } 42 | } else { 43 | console.log('Production locales are up to date!'); 44 | } 45 | }) 46 | .catch(err => { 47 | console.error(err); 48 | process.exit(1); 49 | }); 50 | -------------------------------------------------------------------------------- /scripts/lint-locales.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const cp = require('child_process'); 4 | const { promisify } = require('util'); 5 | const pkg = require('../package.json'); 6 | const conf = require('../server/config'); 7 | 8 | const exec = promisify(cp.exec); 9 | const cmd = `compare-locales l10n.toml . ${getLocales()} --data=json`; 10 | 11 | console.log(cmd); 12 | 13 | exec(cmd) 14 | .then(({ stdout }) => JSON.parse(stdout)) 15 | .then(({ details }) => filterErrors(details)) 16 | .then(results => { 17 | if (results.length) { 18 | results.forEach(({ locale, data }) => { 19 | console.log(locale); 20 | data.forEach(msg => console.log(`- ${msg}`)); 21 | console.log(''); 22 | }); 23 | process.exit(2); 24 | } 25 | }) 26 | .catch(err => { 27 | console.error(err); 28 | process.exit(1); 29 | }); 30 | 31 | function filterErrors(details) { 32 | return Object.keys(details) 33 | .sort() 34 | .map(locale => { 35 | const data = details[locale] 36 | .filter(item => Object.prototype.hasOwnProperty.call(item, 'error')) 37 | .map(({ error }) => error); 38 | return { locale, data }; 39 | }) 40 | .filter(({ data }) => data.length); 41 | } 42 | 43 | function getLocales() { 44 | // If we're in a "production" env (or passed the `--production` flag), only 45 | // check the locales from the package.json file's `availableLanguages` array. 46 | if (conf.env === 'production' || process.argv.includes('--production')) { 47 | return pkg.availableLanguages.sort().join(' '); 48 | } 49 | // Lint all the locales. 50 | return '`ls public/locales`'; 51 | } 52 | -------------------------------------------------------------------------------- /scripts/sync-npm-dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "checking package-lock.json for changes" 4 | IFS=' ' 5 | read -ra G_PARAMS <<< "$HUSKY_GIT_PARAMS" 6 | PREV=${G_PARAMS[0]} 7 | NEXT=${G_PARAMS[1]} 8 | if [ "$PREV" != "$NEXT" ]; then 9 | DIFF=$(git diff $PREV $NEXT package-lock.json) 10 | if [ "$DIFF" != "" ]; then 11 | npm install 12 | fi 13 | fi -------------------------------------------------------------------------------- /server/bin/dev.js: -------------------------------------------------------------------------------- 1 | const assets = require('../../common/assets'); 2 | const routes = require('../routes'); 3 | const pages = require('../routes/pages'); 4 | const tests = require('../../test/frontend/routes'); 5 | const express = require('express'); 6 | const expressWs = require('@dannycoates/express-ws'); 7 | const morgan = require('morgan'); 8 | const config = require('../config'); 9 | 10 | const ID_REGEX = '([0-9a-fA-F]{10, 16})'; 11 | 12 | module.exports = function(app, devServer) { 13 | const wsapp = express(); 14 | expressWs(wsapp, null, { perMessageDeflate: false }); 15 | routes(wsapp); 16 | wsapp.ws('/api/ws', require('../routes/ws')); 17 | wsapp.listen(1338, config.listen_address); 18 | 19 | assets.setMiddleware(devServer.middleware); 20 | app.use(morgan('dev', { stream: process.stderr })); 21 | function android(req, res) { 22 | const index = devServer.middleware.fileSystem 23 | .readFileSync(devServer.middleware.getFilenameFromUrl('/android.html')) 24 | .toString() 25 | .replace( 26 | '', 27 | '' 28 | ); 29 | res.set('Content-Type', 'text/html'); 30 | res.send(index); 31 | } 32 | if (process.env.ANDROID) { 33 | // map all html routes to the android index.html 34 | app.get('/', android); 35 | app.get(`/share/:id${ID_REGEX}`, android); 36 | app.get('/completed', android); 37 | app.get('/preferences', android); 38 | app.get('/options', android); 39 | app.get('/oauth', android); 40 | } 41 | routes(app); 42 | tests(app); 43 | // webpack-dev-server routes haven't been added yet 44 | // so wait for next tick to add 404 handler 45 | process.nextTick(() => app.use(pages.notfound)); 46 | }; 47 | -------------------------------------------------------------------------------- /server/bin/prod.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const Sentry = require('@sentry/node'); 4 | const config = require('../config'); 5 | const routes = require('../routes'); 6 | const pages = require('../routes/pages'); 7 | const expressWs = require('@dannycoates/express-ws'); 8 | 9 | if (config.sentry_dsn) { 10 | Sentry.init({ dsn: config.sentry_dsn }); 11 | } 12 | 13 | const app = express(); 14 | 15 | expressWs(app, null, { perMessageDeflate: false }); 16 | routes(app); 17 | app.ws('/api/ws', require('../routes/ws')); 18 | 19 | app.use( 20 | express.static(path.resolve(__dirname, '../../dist/'), { 21 | setHeaders: function(res, path) { 22 | if (!/serviceWorker\.js$/.test(path)) { 23 | res.set('Cache-Control', 'public, max-age=31536000, immutable'); 24 | } 25 | res.removeHeader('Pragma'); 26 | } 27 | }) 28 | ); 29 | 30 | app.use(pages.notfound); 31 | 32 | app.listen(config.listen_port, config.listen_address); 33 | -------------------------------------------------------------------------------- /server/bin/test.js: -------------------------------------------------------------------------------- 1 | const assets = require('../../common/assets'); 2 | const routes = require('../routes'); 3 | const pages = require('../routes/pages'); 4 | const tests = require('../../test/frontend/routes'); 5 | const expressWs = require('@dannycoates/express-ws'); 6 | 7 | module.exports = function(app, devServer) { 8 | assets.setMiddleware(devServer.middleware); 9 | expressWs(app, null, { perMessageDeflate: false }); 10 | routes(app); 11 | app.ws('/api/ws', require('../routes/ws')); 12 | tests(app); 13 | // webpack-dev-server routes haven't been added yet 14 | // so wait for next tick to add 404 handler 15 | process.nextTick(() => app.use(pages.notfound)); 16 | }; 17 | -------------------------------------------------------------------------------- /server/clientConstants.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | 3 | module.exports = { 4 | LIMITS: { 5 | ANON: { 6 | MAX_FILE_SIZE: config.anon_max_file_size, 7 | MAX_DOWNLOADS: config.anon_max_downloads, 8 | MAX_EXPIRE_SECONDS: config.anon_max_expire_seconds 9 | }, 10 | MAX_FILE_SIZE: config.max_file_size, 11 | MAX_DOWNLOADS: config.max_downloads, 12 | MAX_EXPIRE_SECONDS: config.max_expire_seconds, 13 | MAX_FILES_PER_ARCHIVE: config.max_files_per_archive, 14 | MAX_ARCHIVES_PER_USER: config.max_archives_per_user 15 | }, 16 | DEFAULTS: { 17 | DOWNLOAD_COUNTS: config.download_counts, 18 | EXPIRE_TIMES_SECONDS: config.expire_times_seconds, 19 | EXPIRE_SECONDS: config.default_expire_seconds 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /server/fxa.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const config = require('./config'); 3 | 4 | const KEY_SCOPE = config.fxa_key_scope; 5 | let fxaConfig = null; 6 | let lastConfigRefresh = 0; 7 | 8 | async function getFxaConfig() { 9 | if (fxaConfig && Date.now() - lastConfigRefresh < 1000 * 60 * 5) { 10 | return fxaConfig; 11 | } 12 | try { 13 | const res = await fetch( 14 | `${config.fxa_url}/.well-known/openid-configuration`, 15 | { timeout: 3000 } 16 | ); 17 | fxaConfig = await res.json(); 18 | fxaConfig.key_scope = KEY_SCOPE; 19 | lastConfigRefresh = Date.now(); 20 | } catch (e) { 21 | // continue with previous fxaConfig 22 | } 23 | return fxaConfig; 24 | } 25 | 26 | module.exports = { 27 | getFxaConfig, 28 | verify: async function(token) { 29 | if (!token) { 30 | return null; 31 | } 32 | 33 | const c = await getFxaConfig(); 34 | try { 35 | const verifyUrl = c.jwks_uri.replace('jwks', 'verify'); //HACK 36 | const result = await fetch(verifyUrl, { 37 | method: 'POST', 38 | headers: { 'Content-Type': 'application/json' }, 39 | body: JSON.stringify({ token }) 40 | }); 41 | const info = await result.json(); 42 | if ( 43 | info.scope && 44 | Array.isArray(info.scope) && 45 | info.scope.includes(KEY_SCOPE) 46 | ) { 47 | return info.user; 48 | } 49 | } catch (e) { 50 | // gulp 51 | } 52 | return null; 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /server/initScript.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const raw = require('choo/html/raw'); 3 | const config = require('./config'); 4 | const clientConstants = require('./clientConstants'); 5 | 6 | let sentry = ''; 7 | if (config.sentry_id) { 8 | //eslint-disable-next-line node/no-missing-require 9 | const version = require('../dist/version.json'); 10 | sentry = ` 11 | var SENTRY_CONFIG = { 12 | dsn: '${config.sentry_id}', 13 | release: '${version.version}', 14 | beforeSend: function (data) { 15 | var hash = window.location.hash; 16 | if (hash) { 17 | return JSON.parse(JSON.stringify(data).replace(new RegExp(hash.slice(1), 'g'), '')); 18 | } 19 | return data; 20 | } 21 | } 22 | `; 23 | } 24 | 25 | module.exports = function(state) { 26 | const authConfig = state.authConfig 27 | ? `var AUTH_CONFIG = ${JSON.stringify(state.authConfig)};` 28 | : ''; 29 | 30 | /* eslint-disable no-useless-escape */ 31 | const jsconfig = ` 32 | var isIE = /trident\\\/7\.|msie/i.test(navigator.userAgent); 33 | var isUnsupportedPage = /\\\/unsupported/.test(location.pathname); 34 | if (isIE && !isUnsupportedPage) { 35 | window.location.assign('/unsupported/ie'); 36 | } 37 | if ( 38 | // Firefox < 50 39 | /firefox/i.test(navigator.userAgent) && 40 | parseInt(navigator.userAgent.match(/firefox\\/*([^\\n\\r]*)\./i)[1], 10) < 50 41 | ) { 42 | window.location.assign('/unsupported/outdated'); 43 | } 44 | 45 | var LIMITS = ${JSON.stringify(clientConstants.LIMITS)}; 46 | var DEFAULTS = ${JSON.stringify(clientConstants.DEFAULTS)}; 47 | var PREFS = ${JSON.stringify(state.prefs)}; 48 | var downloadMetadata = ${ 49 | state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}' 50 | }; 51 | ${authConfig}; 52 | ${sentry} 53 | `; 54 | return state.cspNonce 55 | ? html` 56 | 59 | ` 60 | : ''; 61 | }; 62 | -------------------------------------------------------------------------------- /server/keychain.js: -------------------------------------------------------------------------------- 1 | const { Crypto } = require('@peculiar/webcrypto'); 2 | const crypto = new Crypto(); 3 | 4 | const encoder = new TextEncoder(); 5 | const decoder = new TextDecoder(); 6 | 7 | module.exports = class Keychain { 8 | constructor(secretKeyB64) { 9 | if (secretKeyB64) { 10 | this.rawSecret = new Uint8Array(Buffer.from(secretKeyB64, 'base64')); 11 | } else { 12 | throw new Error('key is required'); 13 | } 14 | this.secretKeyPromise = crypto.subtle.importKey( 15 | 'raw', 16 | this.rawSecret, 17 | 'HKDF', 18 | false, 19 | ['deriveKey'] 20 | ); 21 | this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) { 22 | return crypto.subtle.deriveKey( 23 | { 24 | name: 'HKDF', 25 | salt: new Uint8Array(), 26 | info: encoder.encode('metadata'), 27 | hash: 'SHA-256' 28 | }, 29 | secretKey, 30 | { 31 | name: 'AES-GCM', 32 | length: 128 33 | }, 34 | false, 35 | ['decrypt'] 36 | ); 37 | }); 38 | } 39 | 40 | async decryptMetadata(ciphertext) { 41 | const metaKey = await this.metaKeyPromise; 42 | const plaintext = await crypto.subtle.decrypt( 43 | { 44 | name: 'AES-GCM', 45 | iv: new Uint8Array(12), 46 | tagLength: 128 47 | }, 48 | metaKey, 49 | ciphertext 50 | ); 51 | return JSON.parse(decoder.decode(plaintext)); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /server/layout.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const assets = require('../common/assets'); 3 | const initScript = require('./initScript'); 4 | 5 | module.exports = function(state, body = '') { 6 | return html` 7 | 8 | 9 | 10 | ${state.title} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 32 | 38 | 44 | 49 | 50 | 51 | 65 | ${body} ${initScript(state)} 66 | 67 | `; 68 | }; 69 | -------------------------------------------------------------------------------- /server/limiter.js: -------------------------------------------------------------------------------- 1 | const { Transform } = require('stream'); 2 | 3 | class Limiter extends Transform { 4 | constructor(limit) { 5 | super(); 6 | this.limit = limit; 7 | this.length = 0; 8 | } 9 | 10 | _transform(chunk, encoding, callback) { 11 | this.length += chunk.length; 12 | this.push(chunk); 13 | if (this.length > this.limit) { 14 | console.error('LIMIT', this.length, this.limit); 15 | return callback(new Error('limit')); 16 | } 17 | callback(); 18 | } 19 | } 20 | 21 | module.exports = Limiter; 22 | -------------------------------------------------------------------------------- /server/locale.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { FluentBundle } = require('@fluent/bundle'); 4 | const localesPath = path.resolve(__dirname, '../public/locales'); 5 | const locales = fs.readdirSync(localesPath); 6 | 7 | function makeBundle(locale) { 8 | const bundle = new FluentBundle(locale, { useIsolating: false }); 9 | bundle.addMessages( 10 | fs.readFileSync(path.resolve(localesPath, locale, 'send.ftl'), 'utf8') 11 | ); 12 | return [locale, bundle]; 13 | } 14 | 15 | const bundles = new Map(locales.map(makeBundle)); 16 | 17 | module.exports = function getTranslator(locale) { 18 | const defaultBundle = bundles.get('en-US'); 19 | const bundle = bundles.get(locale) || defaultBundle; 20 | return function(id, data) { 21 | if (bundle.hasMessage(id)) { 22 | return bundle.format(bundle.getMessage(id), data); 23 | } 24 | return defaultBundle.format(defaultBundle.getMessage(id), data); 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /server/log.js: -------------------------------------------------------------------------------- 1 | const conf = require('./config'); 2 | 3 | const isProduction = conf.env === 'production'; 4 | 5 | const mozlog = require('mozlog')({ 6 | app: 'FirefoxSend', 7 | level: isProduction ? 'INFO' : 'verbose', 8 | fmt: isProduction ? 'heka' : 'pretty', 9 | debug: !isProduction 10 | }); 11 | 12 | module.exports = mozlog; 13 | -------------------------------------------------------------------------------- /server/metadata.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | function makeToken(secret, counter) { 4 | const hmac = crypto.createHmac('sha256', secret); 5 | hmac.update(String(counter)); 6 | return hmac.digest('hex'); 7 | } 8 | 9 | class Metadata { 10 | constructor(obj, storage) { 11 | this.id = obj.id; 12 | this.dl = +obj.dl || 0; 13 | this.dlToken = +obj.dlToken || 0; 14 | this.dlimit = +obj.dlimit || 1; 15 | this.pwd = !!+obj.pwd; 16 | this.owner = obj.owner; 17 | this.metadata = obj.metadata; 18 | this.auth = obj.auth; 19 | this.nonce = obj.nonce; 20 | this.flagged = !!obj.flagged; 21 | this.dead = !!obj.dead; 22 | this.fxa = !!+obj.fxa; 23 | this.storage = storage; 24 | } 25 | 26 | async getDownloadToken() { 27 | if (this.dlToken >= this.dlimit) { 28 | throw new Error('limit'); 29 | } 30 | this.dlToken = await this.storage.incrementField(this.id, 'dlToken'); 31 | // another request could have also incremented 32 | if (this.dlToken > this.dlimit) { 33 | throw new Error('limit'); 34 | } 35 | return makeToken(this.owner, this.dlToken); 36 | } 37 | 38 | async verifyDownloadToken(token) { 39 | const validTokens = Array.from({ length: this.dlToken }, (_, i) => 40 | makeToken(this.owner, i + 1) 41 | ); 42 | return validTokens.includes(token); 43 | } 44 | } 45 | 46 | module.exports = Metadata; 47 | -------------------------------------------------------------------------------- /server/middleware/language.js: -------------------------------------------------------------------------------- 1 | const { availableLanguages } = require('../../package.json'); 2 | const config = require('../config'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { negotiateLanguages } = require('@fluent/langneg'); 6 | const langData = require('cldr-core/supplemental/likelySubtags.json'); 7 | 8 | // We return early in the middleware if the lang header is long. 9 | // If that ever changes we should re-evaluate this regex. 10 | // eslint-disable-next-line security/detect-unsafe-regex 11 | const acceptLanguages = /(([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\*)(;q=[0-1](\.[0-9]+)?)?/g; 12 | 13 | function allLangs() { 14 | return fs.readdirSync(path.join(__dirname, '..', '..', 'public', 'locales')); 15 | } 16 | 17 | const languages = config.l10n_dev ? allLangs() : availableLanguages; 18 | 19 | module.exports = function(req, res, next) { 20 | const header = req.headers['accept-language'] || 'en-US'; 21 | if (header.length > 255) { 22 | req.language = 'en-US'; 23 | return next(); 24 | } 25 | const langs = header.replace(/\s/g, '').match(acceptLanguages) || ['en-US']; 26 | const preferred = langs 27 | .map(l => { 28 | const parts = l.split(';'); 29 | return { 30 | locale: parts[0], 31 | q: parts[1] ? parseFloat(parts[1].split('=')[1]) : 1 32 | }; 33 | }) 34 | .sort((a, b) => b.q - a.q) 35 | .map(x => x.locale); 36 | req.language = negotiateLanguages(preferred, languages, { 37 | strategy: 'lookup', 38 | likelySubtags: langData.supplemental.likelySubtags, 39 | defaultLocale: 'en-US' 40 | })[0]; 41 | next(); 42 | }; 43 | -------------------------------------------------------------------------------- /server/readme.md: -------------------------------------------------------------------------------- 1 | # Server Code 2 | 3 | The server provides the API, serves static assets, and renders the pages for Send. The production entrypoint is [prod.js](./bin/prod.js) and the development entrypoint is [dev.js](./bin/dev.js) via `webpack-dev-server`. 4 | 5 | ## Server configuration 6 | 7 | [config.js](./config.js) contains the schema for our configuration options. Environment variables are the preferred method for setting configuration. 8 | 9 | ## Middleware 10 | 11 | Contains authentication and localization middleware. 12 | 13 | ## Routes 14 | 15 | Contains all the server routes and handlers for the API and pages 16 | 17 | ## Storage 18 | 19 | Contains implementations of possible storage engines for the files and metadata 20 | -------------------------------------------------------------------------------- /server/routes/delete.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | const { statDeleteEvent } = require('../amplitude'); 3 | 4 | module.exports = async function(req, res) { 5 | try { 6 | const id = req.params.id; 7 | const meta = req.meta; 8 | const ttl = await storage.ttl(id); 9 | await storage.kill(id); 10 | res.sendStatus(200); 11 | statDeleteEvent({ 12 | id, 13 | ip: req.ip, 14 | country: req.geo.country, 15 | state: req.geo.state, 16 | owner: meta.owner, 17 | download_count: meta.dl, 18 | ttl, 19 | agent: req.ua.browser.name || req.ua.ua.substring(0, 6) 20 | }); 21 | } catch (e) { 22 | res.sendStatus(404); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /server/routes/done.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | const { statDownloadEvent } = require('../amplitude'); 3 | 4 | module.exports = async function(req, res) { 5 | try { 6 | const id = req.params.id; 7 | const meta = req.meta; 8 | const ttl = await storage.ttl(id); 9 | statDownloadEvent({ 10 | id, 11 | ip: req.ip, 12 | owner: meta.owner, 13 | download_count: meta.dl, 14 | ttl, 15 | agent: req.ua.browser.name || req.ua.ua.substring(0, 6) 16 | }); 17 | await storage.incrementField(id, 'dl'); 18 | if (meta.dl + 1 >= meta.dlimit) { 19 | // Only dlimit number of tokens will be issued 20 | // after which /download/token will return 403 21 | // however the protocol doesn't prevent one token 22 | // from making all the downloads and assumes 23 | // clients are well behaved. If this becomes 24 | // a problem we can keep track of used tokens. 25 | await storage.kill(id); 26 | } 27 | res.sendStatus(200); 28 | } catch (e) { 29 | res.sendStatus(404); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /server/routes/download.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | 3 | module.exports = async function(req, res) { 4 | const id = req.params.id; 5 | try { 6 | const { length, stream } = await storage.get(id); 7 | res.writeHead(200, { 8 | 'Content-Type': 'application/octet-stream', 9 | 'Content-Length': length 10 | }); 11 | stream.pipe(res); 12 | } catch (e) { 13 | res.sendStatus(404); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /server/routes/exists.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | 3 | module.exports = async (req, res) => { 4 | try { 5 | const meta = await storage.metadata(req.params.id); 6 | if (!meta || meta.dead) { 7 | return res.sendStatus(404); 8 | } 9 | res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); 10 | res.send({ 11 | requiresPassword: meta.pwd 12 | }); 13 | } catch (e) { 14 | res.sendStatus(404); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /server/routes/filelist.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const config = require('../config'); 3 | const storage = require('../storage'); 4 | const Limiter = require('../limiter'); 5 | 6 | function id(user, kid) { 7 | const sha = crypto.createHash('sha256'); 8 | sha.update(user); 9 | sha.update(kid); 10 | const hash = sha.digest('hex'); 11 | return `filelist-${hash}`; 12 | } 13 | 14 | module.exports = { 15 | async get(req, res) { 16 | const kid = req.params.id; 17 | try { 18 | const fileId = id(req.user, kid); 19 | const { length, stream } = await storage.get(fileId); 20 | res.writeHead(200, { 21 | 'Content-Type': 'application/octet-stream', 22 | 'Content-Length': length 23 | }); 24 | stream.pipe(res); 25 | } catch (e) { 26 | res.sendStatus(404); 27 | } 28 | }, 29 | 30 | async post(req, res) { 31 | const kid = req.params.id; 32 | try { 33 | const limiter = new Limiter(1024 * 1024 * 10); 34 | const fileStream = req.pipe(limiter); 35 | await storage.set( 36 | id(req.user, kid), 37 | fileStream, 38 | null, 39 | config.max_expire_seconds 40 | ); 41 | res.sendStatus(200); 42 | } catch (e) { 43 | if (e.message === 'limit') { 44 | return res.sendStatus(413); 45 | } 46 | res.sendStatus(500); 47 | } 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /server/routes/info.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | 3 | module.exports = async function(req, res) { 4 | try { 5 | const ttl = await storage.ttl(req.params.id); 6 | return res.send({ 7 | dlimit: +req.meta.dlimit, 8 | dtotal: +req.meta.dl, 9 | ttl 10 | }); 11 | } catch (e) { 12 | res.sendStatus(404); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /server/routes/metadata.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | 3 | module.exports = async function(req, res) { 4 | const id = req.params.id; 5 | const meta = req.meta; 6 | try { 7 | if (meta.dead && !meta.flagged) { 8 | return res.sendStatus(404); 9 | } 10 | const ttl = await storage.ttl(id); 11 | res.send({ 12 | metadata: meta.metadata, 13 | flagged: !!meta.flagged, 14 | finalDownload: meta.dlToken + 1 === meta.dlimit, 15 | ttl 16 | }); 17 | } catch (e) { 18 | res.sendStatus(404); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /server/routes/metrics.js: -------------------------------------------------------------------------------- 1 | const { sendBatch, clientEvent } = require('../amplitude'); 2 | 3 | module.exports = async function(req, res) { 4 | try { 5 | const data = JSON.parse(req.body); // see http://crbug.com/490015 6 | const deltaT = Date.now() - data.now; 7 | const events = data.events.map(e => 8 | clientEvent( 9 | e, 10 | req.ua, 11 | data.lang, 12 | data.session_id + deltaT, 13 | deltaT, 14 | data.platform, 15 | req.geo.country, 16 | req.geo.state 17 | ) 18 | ); 19 | const status = await sendBatch(events); 20 | res.sendStatus(status); 21 | } catch (e) { 22 | res.sendStatus(500); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /server/routes/pages.js: -------------------------------------------------------------------------------- 1 | const routes = require('../../app/routes'); 2 | const storage = require('../storage'); 3 | const state = require('../state'); 4 | 5 | function stripEvents(str) { 6 | // For CSP we need to remove all the event handler placeholders. 7 | // It's ok, app.js will add them when it attaches to the DOM. 8 | return str.replace(/\son\w+=""/g, ''); 9 | } 10 | 11 | module.exports = { 12 | index: async function(req, res) { 13 | const appState = await state(req); 14 | res.send(stripEvents(routes().toString('/blank', appState))); 15 | }, 16 | 17 | blank: async function(req, res) { 18 | const appState = await state(req); 19 | res.send(stripEvents(routes().toString('/blank', appState))); 20 | }, 21 | 22 | download: async function(req, res, next) { 23 | const id = req.params.id; 24 | const appState = await state(req); 25 | try { 26 | const { nonce, pwd, dead, flagged } = await storage.metadata(id); 27 | if (dead && !flagged) { 28 | return next(); 29 | } 30 | res.set('WWW-Authenticate', `send-v1 ${nonce}`); 31 | res.send( 32 | stripEvents( 33 | routes().toString( 34 | `/download/${id}`, 35 | Object.assign(appState, { 36 | downloadMetadata: { nonce, pwd, flagged } 37 | }) 38 | ) 39 | ) 40 | ); 41 | } catch (e) { 42 | next(); 43 | } 44 | }, 45 | 46 | unsupported: async function(req, res) { 47 | const appState = await state(req); 48 | res.send( 49 | stripEvents( 50 | routes().toString(`/unsupported/${req.params.reason}`, appState) 51 | ) 52 | ); 53 | }, 54 | 55 | notfound: async function(req, res) { 56 | const appState = await state(req); 57 | res 58 | .status(404) 59 | .send( 60 | stripEvents( 61 | routes().toString( 62 | '/404', 63 | Object.assign(appState, { downloadMetadata: { status: 404 } }) 64 | ) 65 | ) 66 | ); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /server/routes/params.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | const storage = require('../storage'); 3 | 4 | module.exports = function(req, res) { 5 | const max = req.meta.fxa ? config.max_downloads : config.anon_max_downloads; 6 | const dlimit = req.body.dlimit; 7 | if (!dlimit || dlimit > max) { 8 | return res.sendStatus(400); 9 | } 10 | 11 | try { 12 | storage.setField(req.params.id, 'dlimit', dlimit); 13 | res.sendStatus(200); 14 | } catch (e) { 15 | res.sendStatus(404); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /server/routes/password.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | 3 | module.exports = function(req, res) { 4 | const id = req.params.id; 5 | const auth = req.body.auth; 6 | if (!auth) { 7 | return res.sendStatus(400); 8 | } 9 | 10 | try { 11 | storage.setField(id, 'auth', auth); 12 | storage.setField(id, 'pwd', 1); 13 | res.sendStatus(200); 14 | } catch (e) { 15 | return res.sendStatus(404); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /server/routes/report.js: -------------------------------------------------------------------------------- 1 | const storage = require('../storage'); 2 | const { statReportEvent } = require('../amplitude'); 3 | 4 | module.exports = async function(req, res) { 5 | try { 6 | const id = req.params.id; 7 | const meta = await storage.metadata(id); 8 | storage.flag(id); 9 | statReportEvent({ 10 | id, 11 | ip: req.ip, 12 | country: req.geo.country, 13 | state: req.geo.state, 14 | owner: meta.owner, 15 | reason: req.body.reason, 16 | download_limit: meta.dlimit, 17 | download_count: meta.dl, 18 | agent: req.ua.browser.name || req.ua.ua.substring(0, 6) 19 | }); 20 | res.sendStatus(200); 21 | } catch (e) { 22 | res.sendStatus(404); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /server/routes/token.js: -------------------------------------------------------------------------------- 1 | module.exports = async function(req, res) { 2 | const meta = req.meta; 3 | try { 4 | if (meta.dead || meta.flagged) { 5 | return res.sendStatus(404); 6 | } 7 | const token = await meta.getDownloadToken(); 8 | res.send({ 9 | token 10 | }); 11 | } catch (e) { 12 | if (e.message === 'limit') { 13 | return res.sendStatus(403); 14 | } 15 | res.sendStatus(404); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /server/routes/upload.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const storage = require('../storage'); 3 | const config = require('../config'); 4 | const mozlog = require('../log'); 5 | const Limiter = require('../limiter'); 6 | const { encryptedSize } = require('../../app/utils'); 7 | 8 | const log = mozlog('send.upload'); 9 | 10 | module.exports = async function(req, res) { 11 | const newId = crypto.randomBytes(8).toString('hex'); 12 | const metadata = req.header('X-File-Metadata'); 13 | const auth = req.header('Authorization'); 14 | if (!metadata || !auth) { 15 | return res.sendStatus(400); 16 | } 17 | const owner = crypto.randomBytes(10).toString('hex'); 18 | const meta = { 19 | owner, 20 | metadata, 21 | auth: auth.split(' ')[1], 22 | nonce: crypto.randomBytes(16).toString('base64') 23 | }; 24 | 25 | try { 26 | const limiter = new Limiter(encryptedSize(config.max_file_size)); 27 | const fileStream = req.pipe(limiter); 28 | //this hasn't been updated to expiration time setting yet 29 | //if you want to fallback to this code add this 30 | await storage.set(newId, fileStream, meta, config.default_expire_seconds); 31 | const protocol = config.env === 'production' ? 'https' : req.protocol; 32 | const url = `${protocol}://${req.get('host')}/download/${newId}/`; 33 | res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); 34 | res.json({ 35 | url, 36 | owner: meta.owner, 37 | id: newId 38 | }); 39 | } catch (e) { 40 | if (e.message === 'limit') { 41 | return res.sendStatus(413); 42 | } 43 | log.error('upload', e); 44 | res.sendStatus(500); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /server/routes/webmanifest.js: -------------------------------------------------------------------------------- 1 | const assets = require('../../common/assets'); 2 | 3 | module.exports = function(req, res) { 4 | const manifest = { 5 | name: 'Firefox Send', 6 | short_name: 'Send', 7 | lang: req.language, 8 | icons: [ 9 | { 10 | src: assets.get('android-chrome-192x192.png'), 11 | type: 'image/png', 12 | sizes: '192x192' 13 | }, 14 | { 15 | src: assets.get('android-chrome-512x512.png'), 16 | type: 'image/png', 17 | sizes: '512x512' 18 | } 19 | ], 20 | start_url: '/', 21 | display: 'standalone', 22 | orientation: 'portrait', 23 | theme_color: '#220033', 24 | background_color: 'white' 25 | }; 26 | res.set('Content-Type', 'application/manifest+json'); 27 | res.json(manifest); 28 | }; 29 | -------------------------------------------------------------------------------- /server/state.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | const layout = require('./layout'); 3 | const assets = require('../common/assets'); 4 | const getTranslator = require('./locale'); 5 | const { getFxaConfig } = require('./fxa'); 6 | 7 | module.exports = async function(req) { 8 | const locale = req.language || 'en-US'; 9 | let authConfig = null; 10 | let robots = 'none'; 11 | if (req.route && req.route.path === '/') { 12 | robots = 'all'; 13 | } 14 | if (config.fxa_client_id) { 15 | try { 16 | authConfig = await getFxaConfig(); 17 | authConfig.client_id = config.fxa_client_id; 18 | authConfig.fxa_required = config.fxa_required; 19 | } catch (e) { 20 | if (config.auth_required) { 21 | throw new Error('fxa_required is set but no config was found'); 22 | } 23 | // continue without accounts 24 | } 25 | } 26 | const prefs = {}; 27 | if (config.survey_url) { 28 | prefs.surveyUrl = config.survey_url; 29 | } 30 | return { 31 | archive: { 32 | numFiles: 0 33 | }, 34 | locale, 35 | capabilities: { account: false }, 36 | translate: getTranslator(locale), 37 | title: 'Firefox Send', 38 | description: 39 | 'Encrypt and send files with a link that automatically expires to ensure your important documents don’t stay online forever.', 40 | baseUrl: config.base_url, 41 | ui: {}, 42 | storage: { 43 | files: [] 44 | }, 45 | fileInfo: {}, 46 | cspNonce: req.cspNonce, 47 | user: { avatar: assets.get('user.svg'), loggedIn: false }, 48 | robots, 49 | authConfig, 50 | prefs, 51 | layout 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /server/storage/fs.js: -------------------------------------------------------------------------------- 1 | const fss = require('fs'); 2 | const fs = fss.promises; 3 | const path = require('path'); 4 | const mkdirp = require('mkdirp'); 5 | 6 | class FSStorage { 7 | constructor(config, log) { 8 | this.log = log; 9 | this.dir = config.file_dir; 10 | mkdirp.sync(this.dir); 11 | } 12 | 13 | async length(id) { 14 | const result = await fs.stat(path.join(this.dir, id)); 15 | return result.size; 16 | } 17 | 18 | getStream(id) { 19 | return fss.createReadStream(path.join(this.dir, id)); 20 | } 21 | 22 | set(id, file) { 23 | return new Promise((resolve, reject) => { 24 | const filepath = path.join(this.dir, id); 25 | const fstream = fss.createWriteStream(filepath); 26 | file.pipe(fstream); 27 | file.on('error', err => { 28 | fstream.destroy(err); 29 | }); 30 | fstream.on('error', err => { 31 | this.del(id); 32 | reject(err); 33 | }); 34 | fstream.on('finish', resolve); 35 | }); 36 | } 37 | 38 | async del(id) { 39 | try { 40 | await fs.unlink(path.join(this.dir, id)); 41 | } catch (e) { 42 | // ignore local fs issues 43 | } 44 | } 45 | 46 | ping() { 47 | return Promise.resolve(); 48 | } 49 | } 50 | 51 | module.exports = FSStorage; 52 | -------------------------------------------------------------------------------- /server/storage/gcs.js: -------------------------------------------------------------------------------- 1 | const { Storage } = require('@google-cloud/storage'); 2 | const storage = new Storage(); 3 | 4 | class GCSStorage { 5 | constructor(config, log) { 6 | this.bucket = storage.bucket(config.gcs_bucket); 7 | this.log = log; 8 | } 9 | 10 | async length(id) { 11 | const data = await this.bucket.file(id).getMetadata(); 12 | return data[0].size; 13 | } 14 | 15 | getStream(id) { 16 | return this.bucket.file(id).createReadStream({ validation: false }); 17 | } 18 | 19 | set(id, file) { 20 | return new Promise((resolve, reject) => { 21 | file 22 | .pipe( 23 | this.bucket.file(id).createWriteStream({ 24 | validation: false, 25 | resumable: true 26 | }) 27 | ) 28 | .on('error', reject) 29 | .on('finish', resolve); 30 | }); 31 | } 32 | 33 | del(id) { 34 | return this.bucket.file(id).delete(); 35 | } 36 | 37 | ping() { 38 | return this.bucket.exists(); 39 | } 40 | } 41 | 42 | module.exports = GCSStorage; 43 | -------------------------------------------------------------------------------- /server/storage/redis.js: -------------------------------------------------------------------------------- 1 | const promisify = require('util').promisify; 2 | 3 | module.exports = function(config) { 4 | const redis_lib = 5 | config.env === 'development' && config.redis_host === 'mock' 6 | ? 'redis-mock' 7 | : 'redis'; 8 | 9 | //eslint-disable-next-line security/detect-non-literal-require 10 | const redis = require(redis_lib); 11 | const client = redis.createClient({ 12 | host: config.redis_host, 13 | retry_strategy: options => { 14 | if (options.total_retry_time > config.redis_retry_time) { 15 | client.emit('error', 'Retry time exhausted'); 16 | return new Error('Retry time exhausted'); 17 | } 18 | 19 | return config.redis_retry_delay; 20 | } 21 | }); 22 | 23 | client.ttlAsync = promisify(client.ttl); 24 | client.hgetallAsync = promisify(client.hgetall); 25 | client.hgetAsync = promisify(client.hget); 26 | client.hincrbyAsync = promisify(client.hincrby); 27 | client.hmgetAsync = promisify(client.hmget); 28 | client.pingAsync = promisify(client.ping); 29 | client.existsAsync = promisify(client.exists); 30 | return client; 31 | }; 32 | -------------------------------------------------------------------------------- /server/storage/s3.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | class S3Storage { 4 | constructor(config, log) { 5 | this.bucket = config.s3_bucket; 6 | this.log = log; 7 | const cfg = {}; 8 | if (config.s3_endpoint != '') { 9 | cfg['endpoint'] = config.s3_endpoint; 10 | } 11 | cfg['s3ForcePathStyle'] = config.s3_use_path_style_endpoint 12 | AWS.config.update(cfg); 13 | this.s3 = new AWS.S3(); 14 | } 15 | 16 | async length(id) { 17 | const result = await this.s3 18 | .headObject({ Bucket: this.bucket, Key: id }) 19 | .promise(); 20 | return Number(result.ContentLength); 21 | } 22 | 23 | getStream(id) { 24 | return this.s3.getObject({ Bucket: this.bucket, Key: id }).createReadStream(); 25 | } 26 | 27 | set(id, file) { 28 | const upload = this.s3.upload({ 29 | Bucket: this.bucket, 30 | Key: id, 31 | Body: file 32 | }); 33 | file.on('error', () => upload.abort()); 34 | return upload.promise(); 35 | } 36 | 37 | del(id) { 38 | return this.s3.deleteObject({ Bucket: this.bucket, Key: id }).promise(); 39 | } 40 | 41 | ping() { 42 | return this.s3.headBucket({ Bucket: this.bucket }).promise(); 43 | } 44 | } 45 | 46 | module.exports = S3Storage; 47 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | 4 | extends: 5 | - plugin:mocha/recommended 6 | 7 | plugins: 8 | - mocha 9 | - node 10 | 11 | rules: 12 | node/no-unpublished-require: off 13 | 14 | mocha/handle-done-callback: error 15 | mocha/no-exclusive-tests: error 16 | mocha/no-identical-title: warn 17 | mocha/no-mocha-arrows: error 18 | mocha/no-nested-tests: error 19 | mocha/no-pending-tests: error 20 | mocha/no-return-and-callback: warn 21 | mocha/no-skipped-tests: error 22 | mocha/no-setup-in-describe: off 23 | mocha/no-hooks-for-single-case: off 24 | 25 | no-console: off # ¯\_(ツ)_/¯ 26 | -------------------------------------------------------------------------------- /test/backend/delete-tests.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | const storage = { 5 | kill: sinon.stub(), 6 | ttl: sinon.stub() 7 | }; 8 | 9 | function request(id) { 10 | return { 11 | params: { id } 12 | }; 13 | } 14 | 15 | function response() { 16 | return { 17 | sendStatus: sinon.stub() 18 | }; 19 | } 20 | 21 | const delRoute = proxyquire('../../server/routes/delete', { 22 | '../storage': storage 23 | }); 24 | 25 | describe('/api/delete', function() { 26 | afterEach(function() { 27 | storage.kill.reset(); 28 | }); 29 | 30 | it('calls storage.kill with the id parameter', async function() { 31 | const req = request('x'); 32 | const res = response(); 33 | await delRoute(req, res); 34 | sinon.assert.calledWith(storage.kill, 'x'); 35 | sinon.assert.calledWith(res.sendStatus, 200); 36 | }); 37 | 38 | it('sends a 404 on failure', async function() { 39 | storage.kill.returns(Promise.reject(new Error())); 40 | const res = response(); 41 | await delRoute(request('x'), res); 42 | sinon.assert.calledWith(res.sendStatus, 404); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/backend/info-tests.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | const storage = { 5 | ttl: sinon.stub() 6 | }; 7 | 8 | function request(id, meta) { 9 | return { 10 | params: { id }, 11 | meta 12 | }; 13 | } 14 | 15 | function response() { 16 | return { 17 | sendStatus: sinon.stub(), 18 | send: sinon.stub() 19 | }; 20 | } 21 | 22 | const infoRoute = proxyquire('../../server/routes/info', { 23 | '../storage': storage 24 | }); 25 | 26 | describe('/api/info', function() { 27 | afterEach(function() { 28 | storage.ttl.reset(); 29 | }); 30 | 31 | it('calls storage.ttl with the id parameter', async function() { 32 | const req = request('x'); 33 | const res = response(); 34 | await infoRoute(req, res); 35 | sinon.assert.calledWith(storage.ttl, 'x'); 36 | }); 37 | 38 | it('sends a 404 on failure', async function() { 39 | storage.ttl.returns(Promise.reject(new Error())); 40 | const res = response(); 41 | await infoRoute(request('x'), res); 42 | sinon.assert.calledWith(res.sendStatus, 404); 43 | }); 44 | 45 | it('returns a json object', async function() { 46 | storage.ttl.returns(Promise.resolve(123)); 47 | const meta = { 48 | dlimit: '1', 49 | dl: '0' 50 | }; 51 | const res = response(); 52 | await infoRoute(request('x', meta), res); 53 | sinon.assert.calledWithMatch(res.send, { 54 | dlimit: 1, 55 | dtotal: 0, 56 | ttl: 123 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/backend/language-tests.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const sinon = require('sinon'); 3 | const proxyquire = require('proxyquire').noCallThru(); 4 | 5 | const config = { 6 | l10n_dev: false // prod configuration 7 | }; 8 | const pkg = { 9 | availableLanguages: ['en-US', 'fr', 'it', 'es-ES'] 10 | }; 11 | 12 | function request(acceptLang) { 13 | return { 14 | headers: { 15 | 'accept-language': acceptLang 16 | } 17 | }; 18 | } 19 | 20 | const langMiddleware = proxyquire('../../server/middleware/language', { 21 | '../config': config, 22 | '../../package.json': pkg 23 | }); 24 | 25 | describe('Language Middleware', function() { 26 | it('defaults to en-US when no header is present', function() { 27 | const req = request(); 28 | const next = sinon.stub(); 29 | langMiddleware(req, null, next); 30 | assert.equal(req.language, 'en-US'); 31 | sinon.assert.calledOnce(next); 32 | }); 33 | 34 | it('sets req.language to en-US when accept-language > 255 chars', function() { 35 | const accept = Array(257).join('a'); 36 | assert.equal(accept.length, 256); 37 | const req = request(accept); 38 | const next = sinon.stub(); 39 | langMiddleware(req, null, next); 40 | assert.equal(req.language, 'en-US'); 41 | sinon.assert.calledOnce(next); 42 | }); 43 | 44 | it('defaults to en-US when no accept-language is available', function() { 45 | const req = request('fa,cs,ja'); 46 | const next = sinon.stub(); 47 | langMiddleware(req, null, next); 48 | assert.equal(req.language, 'en-US'); 49 | sinon.assert.calledOnce(next); 50 | }); 51 | 52 | it('prefers higher q values', function() { 53 | const req = request('fa;q=0.5, it;q=0.9'); 54 | const next = sinon.stub(); 55 | langMiddleware(req, null, next); 56 | assert.equal(req.language, 'it'); 57 | sinon.assert.calledOnce(next); 58 | }); 59 | 60 | it('uses likely subtags', function() { 61 | const req = request('es-MX'); 62 | const next = sinon.stub(); 63 | langMiddleware(req, null, next); 64 | assert.equal(req.language, 'es-ES'); 65 | sinon.assert.calledOn(next); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/backend/metadata-tests.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | const storage = { 5 | ttl: sinon.stub(), 6 | length: sinon.stub() 7 | }; 8 | 9 | function request(id, meta = {}) { 10 | return { 11 | params: { id }, 12 | meta 13 | }; 14 | } 15 | 16 | function response() { 17 | return { 18 | sendStatus: sinon.stub(), 19 | send: sinon.stub() 20 | }; 21 | } 22 | 23 | const metadataRoute = proxyquire('../../server/routes/metadata', { 24 | '../storage': storage 25 | }); 26 | 27 | describe('/api/metadata', function() { 28 | afterEach(function() { 29 | storage.ttl.reset(); 30 | storage.length.reset(); 31 | }); 32 | 33 | it('calls storage.ttl with the id parameter', async function() { 34 | const req = request('x'); 35 | const res = response(); 36 | await metadataRoute(req, res); 37 | sinon.assert.calledWith(storage.ttl, 'x'); 38 | }); 39 | 40 | it('sends a 404 on failure', async function() { 41 | storage.ttl.returns(Promise.reject(new Error())); 42 | const res = response(); 43 | await metadataRoute(request('x'), res); 44 | sinon.assert.calledWith(res.sendStatus, 404); 45 | }); 46 | 47 | it('returns a json object', async function() { 48 | storage.ttl.returns(Promise.resolve(123)); 49 | const meta = { 50 | dlimit: 1, 51 | dlToken: 0, 52 | metadata: 'foo' 53 | }; 54 | const res = response(); 55 | await metadataRoute(request('x', meta), res); 56 | sinon.assert.calledWithMatch(res.send, { 57 | metadata: 'foo', 58 | finalDownload: true, 59 | ttl: 123 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/backend/params-tests.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | const storage = { 5 | setField: sinon.stub() 6 | }; 7 | 8 | function request(id) { 9 | return { 10 | params: { id }, 11 | meta: { fxa: false }, 12 | body: {} 13 | }; 14 | } 15 | 16 | function response() { 17 | return { 18 | sendStatus: sinon.stub() 19 | }; 20 | } 21 | 22 | const paramsRoute = proxyquire('../../server/routes/params', { 23 | '../storage': storage 24 | }); 25 | 26 | describe('/api/params', function() { 27 | afterEach(function() { 28 | storage.setField.reset(); 29 | }); 30 | 31 | it('calls storage.setField with the correct parameter', function() { 32 | const req = request('x'); 33 | const dlimit = 2; 34 | req.body.dlimit = dlimit; 35 | const res = response(); 36 | paramsRoute(req, res); 37 | sinon.assert.calledWith(storage.setField, 'x', 'dlimit', dlimit); 38 | sinon.assert.calledWith(res.sendStatus, 200); 39 | }); 40 | 41 | it('sends a 400 if dlimit is too large', function() { 42 | const req = request('x'); 43 | const res = response(); 44 | req.body.dlimit = 201; 45 | paramsRoute(req, res); 46 | sinon.assert.calledWith(res.sendStatus, 400); 47 | }); 48 | 49 | it('sends a 404 on failure', function() { 50 | storage.setField.throws(new Error()); 51 | const req = request('x'); 52 | const res = response(); 53 | req.body.dlimit = 2; 54 | paramsRoute(req, res); 55 | sinon.assert.calledWith(res.sendStatus, 404); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/backend/password-tests.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | const storage = { 5 | setField: sinon.stub() 6 | }; 7 | 8 | function request(id, body) { 9 | return { 10 | params: { id }, 11 | body 12 | }; 13 | } 14 | 15 | function response() { 16 | return { 17 | sendStatus: sinon.stub() 18 | }; 19 | } 20 | 21 | const passwordRoute = proxyquire('../../server/routes/password', { 22 | '../storage': storage 23 | }); 24 | 25 | describe('/api/password', function() { 26 | afterEach(function() { 27 | storage.setField.reset(); 28 | }); 29 | 30 | it('calls storage.setField with the correct parameter', function() { 31 | const req = request('x', { auth: 'z' }); 32 | const res = response(); 33 | passwordRoute(req, res); 34 | sinon.assert.calledWith(storage.setField, 'x', 'auth', 'z'); 35 | sinon.assert.calledWith(storage.setField, 'x', 'pwd', 1); 36 | sinon.assert.calledWith(res.sendStatus, 200); 37 | }); 38 | 39 | it('sends a 400 if auth is missing', function() { 40 | const req = request('x', {}); 41 | const res = response(); 42 | passwordRoute(req, res); 43 | sinon.assert.calledWith(res.sendStatus, 400); 44 | }); 45 | 46 | it('sends a 404 on failure', function() { 47 | storage.setField.throws(new Error()); 48 | const req = request('x', { auth: 'z' }); 49 | const res = response(); 50 | passwordRoute(req, res); 51 | sinon.assert.calledWith(res.sendStatus, 404); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/frontend/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | 4 | parserOptions: 5 | sourceType: module 6 | 7 | rules: 8 | node/no-unsupported-features: off -------------------------------------------------------------------------------- /test/frontend/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | function kv(f) { 5 | return `require('./tests/${f}')`; 6 | } 7 | 8 | module.exports = function() { 9 | const files = fs 10 | .readdirSync(path.join(__dirname, 'tests')) 11 | .filter(p => /\.js$/.test(p)); 12 | const code = "require('fast-text-encoding');\n" + files.map(kv).join(';\n'); 13 | return { 14 | code, 15 | dependencies: files.map(f => require.resolve('./tests/' + f)), 16 | cacheable: true 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /test/frontend/routes.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html'); 2 | const assets = require('../../common/assets'); 3 | const initScript = require('../../server/initScript'); 4 | 5 | module.exports = function(app) { 6 | app.get('/mocha.css', function(req, res) { 7 | res.sendFile(require.resolve('mocha/mocha.css')); 8 | }); 9 | app.get('/mocha.js', function(req, res) { 10 | res.sendFile(require.resolve('mocha/mocha.js')); 11 | }); 12 | app.get('/test', function(req, res) { 13 | res.send( 14 | html` 15 | 16 | 17 | 18 | 19 | 20 | 33 | ${initScript({ 34 | cspNonce: 'test', 35 | locale: 'en-US' 36 | })} 37 | 38 | 39 | 40 |
41 | 44 | 45 | 46 | `.toString() 47 | ); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /test/frontend/runner.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef, no-process-exit */ 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const mkdirp = require('mkdirp'); 5 | const puppeteer = require('puppeteer'); 6 | const webpack = require('webpack'); 7 | const config = require('../../webpack.config'); 8 | const middleware = require('webpack-dev-middleware'); 9 | const express = require('express'); 10 | const devRoutes = require('../../server/bin/test'); 11 | const app = express(); 12 | 13 | const wpm = middleware(webpack(config(null, { mode: 'development' })), { 14 | logLevel: 'silent' 15 | }); 16 | app.use(wpm); 17 | devRoutes(app, { middleware: wpm }); 18 | 19 | // eslint-disable-next-line no-unused-vars 20 | function onConsole(msg) { 21 | // uncomment to debug 22 | // console.error(msg.text()); 23 | } 24 | 25 | const server = app.listen(async function() { 26 | let exitCode = -1; 27 | const browser = await puppeteer.launch({ 28 | args: [ 29 | // puppeteer >= 1.10.0 crashes on Circle CI without this flag set 30 | '--no-sandbox' 31 | ] 32 | }); 33 | try { 34 | const page = await browser.newPage(); 35 | page.on('console', onConsole); 36 | page.on('pageerror', console.log.bind(console)); 37 | await page.goto(`http://127.0.0.1:${server.address().port}/test`); 38 | await page.waitFor(() => typeof runner.testResults !== 'undefined', { 39 | polling: 1000, 40 | timeout: 15000 41 | }); 42 | const results = await page.evaluate(() => runner.testResults); 43 | const coverage = await page.evaluate(() => __coverage__); 44 | if (coverage) { 45 | const dir = path.resolve(__dirname, '../../.nyc_output'); 46 | mkdirp.sync(dir); 47 | fs.writeFileSync( 48 | path.resolve(dir, 'frontend.json'), 49 | JSON.stringify(coverage) 50 | ); 51 | } 52 | const stats = results.stats; 53 | exitCode = stats.failures; 54 | console.log(`${stats.passes} passing (${stats.duration}ms)\n`); 55 | if (stats.failures) { 56 | console.log('Failures:\n'); 57 | for (const f of results.failures) { 58 | console.log(`${f.fullTitle}`); 59 | console.log(` ${f.err.stack}\n`); 60 | } 61 | } 62 | } catch (e) { 63 | console.log(e); 64 | } finally { 65 | browser.close(); 66 | server.close(() => { 67 | process.exit(exitCode); 68 | }); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /test/frontend/tests/api-tests.js: -------------------------------------------------------------------------------- 1 | /* global DEFAULTS */ 2 | import assert from 'assert'; 3 | import Archive from '../../../app/archive'; 4 | import * as api from '../../../app/api'; 5 | import Keychain from '../../../app/keychain'; 6 | 7 | const encoder = new TextEncoder(); 8 | const plaintext = new Archive([new Blob([encoder.encode('hello world!')])]); 9 | const metadata = { 10 | name: 'test.txt', 11 | type: 'text/plain' 12 | }; 13 | 14 | describe('API', function() { 15 | describe('websocket upload', function() { 16 | it('returns file info on success', async function() { 17 | const keychain = new Keychain(); 18 | const enc = await keychain.encryptStream(plaintext.stream); 19 | const meta = await keychain.encryptMetadata(metadata); 20 | const verifierB64 = await keychain.authKeyB64(); 21 | const p = function() {}; 22 | const up = api.uploadWs( 23 | enc, 24 | meta, 25 | verifierB64, 26 | DEFAULTS.EXPIRE_SECONDS, 27 | 1, 28 | null, 29 | p 30 | ); 31 | 32 | const result = await up.result; 33 | assert.ok(result.url); 34 | assert.ok(result.id); 35 | assert.ok(result.ownerToken); 36 | }); 37 | 38 | it('can be cancelled', async function() { 39 | const keychain = new Keychain(); 40 | const enc = await keychain.encryptStream(plaintext.stream); 41 | const meta = await keychain.encryptMetadata(metadata); 42 | const verifierB64 = await keychain.authKeyB64(); 43 | const p = function() {}; 44 | const up = api.uploadWs( 45 | enc, 46 | meta, 47 | verifierB64, 48 | DEFAULTS.EXPIRE_SECONDS, 49 | null, 50 | p 51 | ); 52 | 53 | up.cancel(); 54 | try { 55 | await up.result; 56 | assert.fail('not cancelled'); 57 | } catch (e) { 58 | assert.equal(e.message, '0'); 59 | } 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/frontend/tests/auth-tests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import storage from '../../../app/storage'; 3 | import { decryptBundle, prepareScopedBundleKey } from '../../../app/fxa'; 4 | import { b64ToArray } from '../../../app/utils'; 5 | 6 | const decoder = new TextDecoder(); 7 | 8 | describe('user auth', function() { 9 | it('prepares ECDH keys for PKCE auth', async function() { 10 | const empty = storage.get('scopedBundlePrivateKey'); 11 | assert.equal(empty, undefined); 12 | const publicKeyB64 = await prepareScopedBundleKey(storage); 13 | const publicKey = JSON.parse(decoder.decode(b64ToArray(publicKeyB64))); 14 | assert(!publicKey.d, 'not a public key'); 15 | assert(publicKey.x); 16 | assert(publicKey.y); 17 | assert.equal(publicKey.kty, 'EC'); 18 | assert.equal(publicKey.crv, 'P-256'); 19 | 20 | const privateKey = JSON.parse(storage.get('scopedBundlePrivateKey')); 21 | storage.remove('scopedBundlePrivateKey'); 22 | assert.equal(privateKey.kty, 'EC'); 23 | assert.equal(privateKey.crv, 'P-256'); 24 | assert(privateKey.d, 'not a private key'); 25 | }); 26 | 27 | it('decrypts the PKCE auth bundle', async function() { 28 | storage.set( 29 | 'scopedBundlePrivateKey', 30 | '{"kty":"EC","kid":"cV9_thVX9XRa-R2nVZF9rFdwrcR_eST4UZuUCx03ebI","crv":"P-256","x":"-0OOb6SPdYBz0CkQLWRu8ojDUhRe-VoKnwLEBi97KAk","y":"U3fXgj1LV7KhiO5O60niMjPpDqToh15-R6C22NnmNXY","d":"KfIQCxZrqSI6j69rAC6fEiGIYKwYv2buQG9NTcKOiGc"}' 31 | ); 32 | const jwks = await decryptBundle( 33 | storage, 34 | 'eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiRUNESC1FUyIsImtpZCI6ImNWOV90aFZYOVhSYS1SMm5WWkY5ckZkd3JjUl9lU1Q0VVp1VUN4MDNlYkkiLCJlcGsiOnsia3R5IjoiRUMiLCJjcnYiOiJQLTI1NiIsIngiOiJqckcwajNFODNodDZJcDE1YmtuZWRUV3kwZmR1WnR0V3NtMkFybUNoQU5rIiwieSI6Ijl3SmNQUDRrQmQ5amtCbEJJcWRhclQ2NjVIQU00SndUX0FSSFc0aTN4QUUifX0..Dkf-FXtakCiPuXjW.-KfVQEntYjUe3f5OxslSQwjLFauc50RurLQHDV75sUixNTlsjTIldCZVb6WUKpQkpOdFHOUYFX9_Cvk2ENKdfcVm2eTuyomlKklHF3q5209KwJz8lDK3gOQuAlz79eDou0k_Z3JNGu-qZ8IiDhZZ9iNSgBrsq0BZwVXZ9ViSFEW-YzJBQlKmildscXhp_-Lf6-qiJJrPbZCXFD3PZmzcule3kyBOarg_fjjHLFlIpdjP1lI5wBETqdjk7iBKeO2isSQO7-8.q5EzqP6OPg9yb5BcJH2oFg' 35 | ); 36 | assert.deepEqual(jwks, { 37 | 'https://identity.mozilla.com/apps/send': { 38 | kty: 'oct', 39 | scope: 'https://identity.mozilla.com/apps/send', 40 | k: '5_jrbS76RzJ4EwlKSl527vqz3BDqf5DM4sNsoEK_hoA', 41 | kid: '1414456160-n6yE-eL-ADvnsJo_huq3DA' 42 | } 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/frontend/tests/fileSender-tests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import FileSender from '../../../app/fileSender'; 3 | import Archive from '../../../app/archive'; 4 | 5 | // FileSender uses a File in real life but a Blob works for testing 6 | const blob = new Blob(['hello world!'], { type: 'text/plain' }); 7 | blob.name = 'text.txt'; 8 | const archive = new Archive([blob]); 9 | 10 | describe('FileSender', function() { 11 | describe('upload', function() { 12 | it('returns an OwnedFile on success', async function() { 13 | const fs = new FileSender(); 14 | const file = await fs.upload(archive); 15 | assert.ok(file.id); 16 | assert.equal(file.name, archive.name); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/frontend/tests/keychain-tests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import Keychain from '../../../app/keychain'; 3 | 4 | describe('Keychain', function() { 5 | describe('setPassword', function() { 6 | it('changes the authKey', async function() { 7 | const k = new Keychain(); 8 | const original = await k.authKeyB64(); 9 | k.setPassword('foo', 'some://url'); 10 | const pwd = await k.authKeyB64(); 11 | assert.notEqual(pwd, original); 12 | }); 13 | }); 14 | 15 | describe('encrypt / decrypt metadata', function() { 16 | it('can decrypt metadata it encrypts', async function() { 17 | const k = new Keychain(); 18 | const meta = { 19 | name: 'foo', 20 | type: 'bar/baz' 21 | }; 22 | const ciphertext = await k.encryptMetadata(meta); 23 | const result = await k.decryptMetadata(ciphertext); 24 | assert.equal(result.name, meta.name); 25 | assert.equal(result.type, meta.type); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/integration/fixtures/txt-small-testfile.txt: -------------------------------------------------------------------------------- 1 | THIS IS A TEST! 2 | -------------------------------------------------------------------------------- /test/integration/homepage-tests.js: -------------------------------------------------------------------------------- 1 | /* global browser */ 2 | const assert = require('assert'); 3 | const HomePage = require('./pages/desktop/home_page'); 4 | 5 | describe('Firefox Send homepage', function() { 6 | this.retries(2); 7 | const homePage = new HomePage(); 8 | const baseUrl = browser.options['baseUrl']; 9 | const footerLinks = ['mozilla', 'cookies', 'github']; 10 | 11 | beforeEach(function() { 12 | homePage.open(); 13 | if (process.env.ANDROID) { 14 | this.skip(); 15 | } 16 | }); 17 | 18 | it('should have the right title', function() { 19 | assert.equal(browser.getTitle(), 'Firefox Send'); 20 | }); 21 | 22 | footerLinks.forEach((link, i) => { 23 | it(`should navigate to the correct page: ${link}`, function() { 24 | // Click links on bottom of page 25 | const els = browser.elements(homePage.footerLinks); 26 | browser.elementIdClick(els.value[i].ELEMENT); 27 | // Wait for page to load 28 | browser.waitUntil(() => { 29 | const url = browser.getUrl(); 30 | return url !== baseUrl; 31 | }); 32 | assert.ok(browser.getUrl().includes(link)); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/integration/pages/desktop/download_page.js: -------------------------------------------------------------------------------- 1 | /* global browser */ 2 | const Page = require('./page'); 3 | 4 | class DownloadPage extends Page { 5 | constructor(path) { 6 | super(path); 7 | this.fileId = /download\/(\w+)\/\??.*#/.exec(path)[1]; 8 | this.downloadButton = '#download-btn'; 9 | this.downloadComplete = '#download-complete'; 10 | this.passwordInput = '#password-input'; 11 | this.passwordButton = '#password-btn'; 12 | } 13 | 14 | downloadUsingPassword(password) { 15 | browser.waitForExist(this.passwordInput); 16 | browser.setValue(this.passwordInput, password); 17 | browser.click(this.passwordButton); 18 | return browser.click(this.downloadButton); 19 | } 20 | 21 | download() { 22 | browser.waitForExist(this.downloadButton); 23 | return browser.click(this.downloadButton); 24 | } 25 | } 26 | module.exports = DownloadPage; 27 | -------------------------------------------------------------------------------- /test/integration/pages/desktop/home_page.js: -------------------------------------------------------------------------------- 1 | /* global browser document */ 2 | const Page = require('./page'); 3 | 4 | class HomePage extends Page { 5 | constructor() { 6 | super('/'); 7 | this.footerLinks = 'footer a'; 8 | this.uploadInput = '#file-upload'; 9 | this.uploadButton = '#upload-btn'; 10 | this.progress = 'progress'; 11 | this.shareUrl = '#share-url'; 12 | this.downloadCountSelect = '#expire-after-dl-count-select'; 13 | this.addPassword = '#add-password'; 14 | this.passwordInput = '#password-input'; 15 | this.passwordButton = '#password-btn'; 16 | } 17 | 18 | waitForPageToLoad() { 19 | super.waitForPageToLoad(); 20 | browser.waitForExist(this.uploadInput); 21 | this.showUploadInput(); 22 | return this; 23 | } 24 | 25 | showUploadInput() { 26 | browser.execute(() => { 27 | document.getElementById('file-upload').style.display = 'block'; 28 | }); 29 | } 30 | } 31 | module.exports = HomePage; 32 | -------------------------------------------------------------------------------- /test/integration/pages/desktop/page.js: -------------------------------------------------------------------------------- 1 | /* global browser window */ 2 | class Page { 3 | constructor(path) { 4 | this.path = path; 5 | } 6 | 7 | open() { 8 | browser.url(this.path); 9 | this.waitForPageToLoad(); 10 | } 11 | 12 | /** 13 | * @function waitForPageToLoad 14 | * @returns {Object} An object representing the page. 15 | * @throws ElementNotFound 16 | */ 17 | waitForPageToLoad() { 18 | browser.waitUntil(function() { 19 | return browser.execute(function() { 20 | return typeof window.app !== 'undefined'; 21 | }); 22 | }, 3000); 23 | browser.pause(100); 24 | return this; 25 | } 26 | } 27 | module.exports = Page; 28 | -------------------------------------------------------------------------------- /test/integration/progress-tests.js: -------------------------------------------------------------------------------- 1 | /* global browser */ 2 | const assert = require('assert'); 3 | const HomePage = require('./pages/desktop/home_page'); 4 | 5 | describe('Firefox Send progress page', function() { 6 | const homePage = new HomePage(); 7 | beforeEach(function() { 8 | homePage.open(); 9 | }); 10 | 11 | it('should show progress when a file is uploading', function() { 12 | browser.chooseFile(homePage.uploadInput, __filename); 13 | browser.waitForExist(homePage.uploadButton); 14 | browser.click(homePage.uploadButton); 15 | 16 | assert.ok(browser.waitForExist(homePage.progress)); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/readme.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | To run all the tests use `npm test`. This will run the tests and produce a code coverage report at [coverage/index.html](../coverage/index.html). The full test suite is run as a hook on each `git push`. [Mocha](https://mochajs.org) is our preferred test runner. 4 | 5 | ## Frontend 6 | 7 | Unit tests reside in `test/frontend/tests`. 8 | 9 | Frontend tests can be ran in the browser by running `npm start` and then browsing to http://localhost:8080/test. Doing it this way will watch for changes and rerun the suite automatically. 10 | 11 | You can also run them in headless Chrome by using `npm run test:frontend`. The results will be printed to the console. 12 | 13 | ## Backend 14 | 15 | Unit tests reside in `test/backend` 16 | 17 | Backend test can be run with `npm run test:backend`. [Sinon](http://sinonjs.org/) and [proxyquire](https://github.com/thlorenz/proxyquire) are used for mocking. 18 | 19 | ## Integration 20 | 21 | Integration tests include UI tests that run with Selenium. 22 | 23 | The preferred way to run these locally is with `npm run test-integration` which requires docker. To watch the tests connect with VNC. On mac enter `vnc://localhost:5900` in Safari and use the password `secret` to connect. For info on debugging a test see the [wdio debug docs](http://webdriver.io/api/utility/debug.html). 24 | -------------------------------------------------------------------------------- /test/testServer.js: -------------------------------------------------------------------------------- 1 | let server = null; 2 | 3 | module.exports = { 4 | onPrepare: function() { 5 | return new Promise(function(resolve) { 6 | const webpack = require('webpack'); 7 | const middleware = require('webpack-dev-middleware'); 8 | const express = require('express'); 9 | const expressWs = require('@dannycoates/express-ws'); 10 | const assets = require('../common/assets'); 11 | const routes = require('../server/routes'); 12 | const tests = require('./frontend/routes'); 13 | const app = express(); 14 | const config = require('../webpack.config'); 15 | const wpm = middleware(webpack(config(null, { mode: 'development' })), { 16 | logLevel: 'silent' 17 | }); 18 | app.use(wpm); 19 | assets.setMiddleware(wpm); 20 | expressWs(app, null, { perMessageDeflate: false }); 21 | routes(app); 22 | app.ws('/api/ws', require('../server/routes/ws')); 23 | tests(app); 24 | wpm.waitUntilValid(() => { 25 | server = app.listen(8000, resolve); 26 | }); 27 | }); 28 | }, 29 | onComplete: function() { 30 | server.close(); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /test/wdio.circleci.conf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-extraneous-require 2 | const ip = require('ip'); 3 | const common = require('./wdio.common.conf'); 4 | 5 | /*/ 6 | 7 | Config for running selenium from a circleci docker container against localhost 8 | 9 | /*/ 10 | 11 | exports.config = Object.assign({}, common.config, { 12 | baseUrl: `http://${ip.address()}:8000`, 13 | maxInstances: 1, 14 | bail: 1, 15 | services: [require('./testServer'), 'selenium-standalone'] 16 | }); 17 | -------------------------------------------------------------------------------- /test/wdio.common.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const mkdirp = require('mkdirp'); 3 | const rimraf = require('rimraf'); 4 | const dir = path.join(__dirname, 'integration', 'downloads'); 5 | 6 | mkdirp.sync(dir); 7 | rimraf.sync(`${dir}${path.sep}*`); 8 | 9 | exports.config = { 10 | specs: [path.join(__dirname, './integration/**/*-tests.js')], 11 | exclude: [], 12 | maxInstances: 10, 13 | capabilities: [ 14 | { 15 | browserName: 'firefox', 16 | 'moz:firefoxOptions': { 17 | log: { level: 'trace' }, 18 | prefs: { 19 | 'browser.download.panel.shown': false, 20 | 'browser.helperApps.neverAsk.openFile': 'text/plain', 21 | 'browser.helperApps.neverAsk.saveToDisk': 'text/plain', 22 | 'browser.download.folderList': 2, 23 | 'browser.download.dir': dir 24 | } 25 | } 26 | } 27 | ], 28 | pageLoadStrategy: 'normal', 29 | watch: false, 30 | async: true, 31 | logLevel: 'error', 32 | coloredLogs: true, 33 | deprecationWarnings: true, 34 | bail: 0, 35 | screenshotOnReject: false, 36 | baseUrl: 'http://localhost:8000', 37 | waitforTimeout: 20000, 38 | connectionRetryTimeout: 90000, 39 | connectionRetryCount: 3, 40 | services: ['firefox-profile'], 41 | framework: 'mocha', 42 | reporters: ['dot', 'spec'], 43 | mochaOpts: { 44 | ui: 'bdd', 45 | timeout: 30000, 46 | retries: 1 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /test/wdio.docker.conf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-extraneous-require 2 | const ip = require('ip'); 3 | const common = require('./wdio.common.conf'); 4 | const dir = 5 | common.config.capabilities[0]['moz:firefoxOptions'].prefs[ 6 | 'browser.download.dir' 7 | ]; 8 | 9 | /*/ 10 | 11 | Config for running selenium in a new docker container against localhost 12 | 13 | /*/ 14 | 15 | exports.config = Object.assign({}, common.config, { 16 | baseUrl: `http://${ip.address()}:8000`, 17 | maxInstances: 1, 18 | services: ['docker', require('./testServer')], 19 | dockerOptions: { 20 | image: 'selenium/standalone-firefox-debug', 21 | healthCheck: 'http://localhost:4444', 22 | options: { 23 | p: ['4444:4444', '5900:5900'], 24 | mount: `type=bind,source=${dir},destination=${dir},consistency=delegated`, 25 | shmSize: '2g' 26 | } 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /test/wdio.local.conf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-extraneous-require 2 | const ip = require('ip'); 3 | const common = require('./wdio.common.conf'); 4 | 5 | /*/ 6 | 7 | Config for running selenium against localhost 8 | 9 | /*/ 10 | 11 | exports.config = Object.assign({}, common.config, { 12 | baseUrl: `http://${ip.address()}:8000`, 13 | maxInstances: 1, 14 | bail: 1, 15 | services: [require('./testServer')] 16 | }); 17 | -------------------------------------------------------------------------------- /test/wdio.remote.config.js: -------------------------------------------------------------------------------- 1 | const common = require('./wdio.common.conf'); 2 | const path = require('path'); 3 | 4 | /*/ 5 | 6 | Config for running saucelabs against a hosted server 7 | 8 | /*/ 9 | 10 | exports.config = Object.assign({}, common.config, { 11 | baseUrl: process.env.TEST_SERVER || 'https://send.dev.mozaws.net', 12 | exclude: [ 13 | // the /test endpoint only exists on localhost 14 | path.join(__dirname, './integration/unit-tests.js'), 15 | // we don't have access to the fs in this context 16 | path.join(__dirname, './integration/download-tests.js') 17 | ], 18 | capabilities: [ 19 | { browserName: 'firefox' }, 20 | { browserName: 'chrome' }, 21 | { browserName: 'MicrosoftEdge' }, 22 | { 23 | browserName: 'safari' 24 | } 25 | ], 26 | services: ['sauce'], 27 | user: process.env.SAUCE_USERNAME, 28 | key: process.env.SAUCE_ACCESS_KEY 29 | }); 30 | -------------------------------------------------------------------------------- /test/wdio.saucelabs.config.js: -------------------------------------------------------------------------------- 1 | const common = require('./wdio.common.conf'); 2 | const path = require('path'); 3 | 4 | /*/ 5 | 6 | Config for running saucelabs against localhost 7 | 8 | /*/ 9 | 10 | exports.config = Object.assign({}, common.config, { 11 | maxInstances: 2, 12 | exclude: [path.join(__dirname, './integration/download-tests.js')], 13 | capabilities: [ 14 | { browserName: 'firefox' }, 15 | { browserName: 'chrome' }, 16 | { browserName: 'MicrosoftEdge' }, 17 | { 18 | browserName: 'safari' 19 | } 20 | ], 21 | services: ['sauce', require('./testServer')], 22 | sauceConnect: true, 23 | sauceConnectOpts: { 24 | // uncomment to debug 25 | // logfile: __dirname + '/sc.log', 26 | // verbose: true 27 | }, 28 | user: process.env.SAUCE_USERNAME, 29 | key: process.env.SAUCE_ACCESS_KEY 30 | }); 31 | --------------------------------------------------------------------------------