Merge pull request #1198 from mozilla/vnext

Merge vnext into master
This commit is contained in:
Danny Coates 2019-03-07 10:21:10 -08:00 committed by GitHub
commit 0371f1906a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
372 changed files with 20798 additions and 13589 deletions

View File

@ -5,7 +5,6 @@ node_modules
firefox firefox
assets assets
docs docs
public
test test
coverage coverage
.nyc_output .nyc_output

View File

@ -2,3 +2,5 @@ dist
assets assets
firefox firefox
coverage coverage
app/locale.js
app/capabilities.js

View File

@ -14,6 +14,9 @@ plugins:
root: true root: true
rules: rules:
node/no-deprecated-api: off
node/no-unsupported-features/es-syntax: off
node/no-unsupported-features/node-builtins: off
node/no-unpublished-require: off node/no-unpublished-require: off
security/detect-non-literal-fs-filename: off security/detect-non-literal-fs-filename: off

7
.gitignore vendored
View File

@ -6,3 +6,10 @@ dist
.nyc_output .nyc_output
.tox .tox
.pytest_cache .pytest_cache
*.iml
android/app/src/main/assets
ios/send-ios/assets/ios.js
ios/send-ios/assets/vendor.js
ios/send-ios.xcodeproj/project.xcworkspace/xcuserdata/*
ios/send-ios.xcodeproj/xcuserdata/*
test/integration/downloads

View File

@ -1,3 +1,4 @@
dist dist
assets/*.js android/app/src/main/assets
android/app/build
coverage coverage

View File

@ -1,7 +0,0 @@
# autogenerated pyup.io config file
# see https://pyup.io/docs/configuration/ for all available options
schedule: every week
requirements:
- test/integration/Pipfile
- test/integration/pipenv.txt

View File

@ -10,3 +10,4 @@ rules:
declaration-colon-newline-after: null declaration-colon-newline-after: null
selector-list-comma-newline-after: null selector-list-comma-newline-after: null
value-list-comma-newline-after: null value-list-comma-newline-after: null
at-rule-no-unknown: null

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

View File

@ -14,4 +14,4 @@ COPY --chown=app:app . .
ENV PORT=1443 ENV PORT=1443
EXPOSE $PORT EXPOSE $PORT
CMD ["node", "server/prod.js"] CMD ["node", "server/bin/prod.js"]

View File

@ -2,7 +2,6 @@
[![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=aFFIMHNEWFcrNHJaMU1LRkJnUDhOQkNHMmh2WHBscjJsZHcwK1h0dkhwdz0tLXRpN1RXcysybUtxTFFTVGRtWjVGeHc9PQ==--c56129be8c75941b115c5b5e5d3ed10b3c7dca6b)](https://www.browserstack.com/automate/public-build/aFFIMHNEWFcrNHJaMU1LRkJnUDhOQkNHMmh2WHBscjJsZHcwK1h0dkhwdz0tLXRpN1RXcysybUtxTFFTVGRtWjVGeHc9PQ==--c56129be8c75941b115c5b5e5d3ed10b3c7dca6b) [![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=aFFIMHNEWFcrNHJaMU1LRkJnUDhOQkNHMmh2WHBscjJsZHcwK1h0dkhwdz0tLXRpN1RXcysybUtxTFFTVGRtWjVGeHc9PQ==--c56129be8c75941b115c5b5e5d3ed10b3c7dca6b)](https://www.browserstack.com/automate/public-build/aFFIMHNEWFcrNHJaMU1LRkJnUDhOQkNHMmh2WHBscjJsZHcwK1h0dkhwdz0tLXRpN1RXcysybUtxTFFTVGRtWjVGeHc9PQ==--c56129be8c75941b115c5b5e5d3ed10b3c7dca6b)
[![CircleCI](https://img.shields.io/circleci/project/github/mozilla/send.svg)](https://circleci.com/gh/mozilla/send) [![CircleCI](https://img.shields.io/circleci/project/github/mozilla/send.svg)](https://circleci.com/gh/mozilla/send)
[![Available on Test Pilot](https://img.shields.io/badge/available_on-Test_Pilot-0996F8.svg)](https://testpilot.firefox.com/experiments/send)
**Docs:** [FAQ](docs/faq.md), [Encryption](docs/encryption.md), [Build](docs/build.md), [Docker](docs/docker.md), [Metrics](docs/metrics.md), [More](docs/) **Docs:** [FAQ](docs/faq.md), [Encryption](docs/encryption.md), [Build](docs/build.md), [Docker](docs/docker.md), [Metrics](docs/metrics.md), [More](docs/)
@ -18,6 +17,7 @@
* [Localization](#localization) * [Localization](#localization)
* [Contributing](#contributing) * [Contributing](#contributing)
* [Testing](#testing) * [Testing](#testing)
* [Android](#android)
* [License](#license) * [License](#license)
--- ---
@ -30,7 +30,7 @@ A file sharing experiment which allows you to send encrypted files to other user
## Requirements ## Requirements
- [Node.js 8.2+](https://nodejs.org/) - [Node.js 10.0+](https://nodejs.org/)
- [Redis server](https://redis.io/) (optional for development) - [Redis server](https://redis.io/) (optional for development)
- [AWS S3](https://aws.amazon.com/s3/) or compatible service. (optional) - [AWS S3](https://aws.amazon.com/s3/) or compatible service. (optional)
@ -92,6 +92,12 @@ Pull requests are always welcome! Feel free to check out the list of ["good firs
--- ---
## Android
The android implementation is contained in the `android` directory, and can be viewed locally for easy testing and editing by running `ANDROID=1 npm start` and then visiting <http://localhost:8080>. CSS and image files are located in the `android/app/src/main/assets` directory.
---
## License ## License
[Mozilla Public License Version 2.0](LICENSE) [Mozilla Public License Version 2.0](LICENSE)

6
android/.eslintrc.yaml Normal file
View File

@ -0,0 +1,6 @@
env:
browser: true
parserOptions:
sourceType: module

4
android/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
local.properties
.gradle
build

9
android/README.md Normal file
View File

@ -0,0 +1,9 @@
Readme
=====
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.
Building the Send Android app.
=====
First, install Android Studio. Open the `android` directory in Android Studio, plug in your android phone, and press the run button.

19
android/SendAndroid.iml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.linked.project.id="SendAndroid" external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$" external.system.id="GRADLE" type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="java-gradle" name="Java-Gradle">
<configuration>
<option name="BUILD_FOLDER_PATH" value="$MODULE_DIR$/build" />
<option name="BUILDABLE" value="false" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_1_7" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.gradle" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

93
android/android.js Normal file
View File

@ -0,0 +1,93 @@
/* global window, navigator */
import choo from 'choo';
import html from 'choo/html';
import Raven from 'raven-js';
import { setApiUrlPrefix, getConstants } from '../app/api';
import metrics from '../app/metrics';
//import assets from '../common/assets';
import Archive from '../app/archive';
import Header from '../app/ui/header';
import storage from '../app/storage';
import controller from '../app/controller';
import User from './user';
import intents from './stores/intents';
import home from './pages/home';
import upload from './pages/upload';
import share from './pages/share';
import preferences from './pages/preferences';
import error from './pages/error';
import { getTranslator } from '../app/locale';
if (navigator.userAgent === 'Send Android') {
setApiUrlPrefix('https://send2.dev.lcip.org');
}
const app = choo();
//app.use(state);
app.use(controller);
app.use(intents);
function body(main) {
return function(state, emit) {
/*
Disable the preferences menu for now since it is ugly and isn't
relevant to the beta
function clickPreferences(event) {
event.preventDefault();
emit('pushState', '/preferences');
}
const menu = html`<a
id="hamburger"
class="absolute pin-t pin-r z-50"
href="#"
onclick="${clickPreferences}"
>
<img src="${assets.get('preferences.png')}" />
</a>`;
*/
return html`
<body
class="flex flex-col items-center font-sans bg-grey-lightest h-screen"
>
${state.cache(Header, 'header').render()} ${main(state, emit)}
</body>
`;
};
}
(async function start() {
const translate = await getTranslator('en-US');
const { LIMITS, DEFAULTS } = await getConstants();
app.use((state, emitter) => {
state.LIMITS = LIMITS;
state.DEFAULTS = DEFAULTS;
state.translate = translate;
state.capabilities = {
account: true
}; //TODO
state.archive = new Archive([], DEFAULTS.EXPIRE_SECONDS);
state.storage = storage;
state.user = new User(storage, LIMITS);
state.raven = Raven;
window.finishLogin = async function(accountInfo) {
await state.user.finishLogin(accountInfo);
await state.user.syncFileList();
emitter.emit('replaceState', '/');
};
// for debugging
window.appState = state;
window.appEmit = emitter.emit.bind(emitter);
});
app.use(metrics);
app.route('/', body(home));
app.route('/upload', upload);
app.route('/share/:id', share);
app.route('/preferences', preferences);
app.route('/error', error);
//app.route('/debugging', require('./pages/debugging').default);
// add /api/filelist
app.mount('body');
})();

1
android/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

43
android/app/build.gradle Normal file
View File

@ -0,0 +1,43 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 27
defaultConfig {
applicationId "org.mozilla.sendandroid"
minSdkVersion 26
targetSdkVersion 27
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:27.1.1'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
implementation 'com.github.delight-im:Android-AdvancedWebView:v3.0.0'
implementation "org.mozilla.components:service-firefox-accounts:${rootProject.ext.android_components_version}"
}
task generateAndLinkBundle(type: Exec, description: 'Generate the android.js bundle and link it into the assets directory') {
commandLine './buildAssets.sh'
}
tasks.withType(JavaCompile) {
compileTask -> compileTask.dependsOn generateAndLinkBundle
}

12
android/app/buildAssets.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
if [ -d "../../node_modules" ]
then
echo "node_modules already present."
else
echo "node_modules not present, running npm install."
npm install
fi
npm run build
rm -rf src/main/assets
mkdir -p src/main/assets
cp -R ../../dist/* src/main/assets

21
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.mozilla.sendandroid">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<meta-data android:name="android.webkit.WebView.EnableSafeBrowsing" android:value="false" />
<activity android:name=".MainActivity" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,220 @@
package org.mozilla.sendandroid
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import im.delight.android.webview.AdvancedWebView
import android.graphics.Bitmap
import android.content.Context
import android.content.Intent
import android.annotation.SuppressLint
import android.content.ComponentName
import android.net.Uri
import android.webkit.WebView
import android.webkit.WebMessage
import android.util.Log
import android.util.Base64
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import mozilla.components.service.fxa.Config
import mozilla.components.service.fxa.FirefoxAccount
import mozilla.components.service.fxa.OAuthInfo
import mozilla.components.service.fxa.Profile
import mozilla.components.service.fxa.FxaResult
internal class LoggingWebChromeClient : WebChromeClient() {
override fun onConsoleMessage(cm: ConsoleMessage): Boolean {
Log.w("CONTENT", String.format("%s @ %d: %s",
cm.message(), cm.lineNumber(), cm.sourceId()))
return true
}
}
class WebAppInterface(private val mContext: MainActivity) {
@JavascriptInterface
fun beginOAuthFlow() {
mContext.beginOAuthFlow();
}
@JavascriptInterface
fun shareUrl(url: String) {
mContext.shareUrl(url)
}
}
class MainActivity : AppCompatActivity(), AdvancedWebView.Listener {
private var mWebView: AdvancedWebView? = null
private var mToShare: String? = null
private var mToCall: String? = null
private var mAccount: FirefoxAccount? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews
// WebView.setWebContentsDebuggingEnabled(true); // TODO only dev builds
mWebView = findViewById<WebView>(R.id.webview) as AdvancedWebView
mWebView!!.setListener(this, this)
mWebView!!.setWebChromeClient(LoggingWebChromeClient())
mWebView!!.addJavascriptInterface(WebAppInterface(this), "Android")
mWebView!!.setLayerType(View.LAYER_TYPE_HARDWARE, null);
val webSettings = mWebView!!.getSettings()
webSettings.setUserAgentString("Send Android")
webSettings.setAllowUniversalAccessFromFileURLs(true)
webSettings.setJavaScriptEnabled(true)
val intent = getIntent()
val action = intent.getAction()
val type = intent.getType()
if (Intent.ACTION_SEND.equals(action) && type != null) {
if (type.equals("text/plain")) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
Log.w("INTENT", "text/plain " + sharedText)
mToShare = "data:text/plain;base64," + Base64.encodeToString(sharedText.toByteArray(), 16).trim()
} else if (type.startsWith("image/")) {
val imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as Uri
Log.w("INTENT", "image/ " + imageUri)
mToShare = "data:text/plain;base64," + Base64.encodeToString(imageUri.path.toByteArray(), 16).trim()
}
}
mWebView!!.loadUrl("file:///android_asset/android.html")
}
fun beginOAuthFlow() {
Config.release().then(fun (value: Config): FxaResult<Unit> {
mAccount = FirefoxAccount(value, "20f7931c9054d833", "https://send.firefox.com/fxa/android-redirect.html")
mAccount?.beginOAuthFlow(arrayOf("profile", "https://identity.mozilla.com/apps/send"), true)?.then(fun (url: String): FxaResult<Unit> {
Log.w("CONFIG", "GOT A URL " + url)
this@MainActivity.runOnUiThread({
mWebView!!.loadUrl(url)
})
return FxaResult.fromValue(Unit)
})
Log.w("CONFIG", "CREATED FIREFOXACCOUNT")
return FxaResult.fromValue(Unit)
})
}
fun shareUrl(url: String) {
val shareIntent = Intent()
shareIntent.action = Intent.ACTION_SEND
shareIntent.type = "text/plain"
shareIntent.putExtra(Intent.EXTRA_TEXT, url)
val chooser = Intent.createChooser(shareIntent, "")
chooser.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(applicationContext, MainActivity::class.java)))
startActivity(chooser)
}
@SuppressLint("NewApi")
override fun onResume() {
super.onResume()
mWebView!!.onResume()
// ...
}
@SuppressLint("NewApi")
override fun onPause() {
mWebView!!.onPause()
// ...
super.onPause()
}
override fun onDestroy() {
mWebView!!.onDestroy()
// ...
super.onDestroy()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
mWebView!!.onActivityResult(requestCode, resultCode, intent)
// ...
}
override fun onBackPressed() {
if (!mWebView!!.onBackPressed()) {
return
}
// ...
super.onBackPressed()
}
override fun onPageStarted(url: String, favicon: Bitmap?) {
if (url.startsWith("https://send.firefox.com/fxa/android-redirect.html")) {
// We load this here so the user doesn't see the android-redirect.html page
mWebView!!.loadUrl("file:///android_asset/android.html")
val parsed = Uri.parse(url)
val code = parsed.getQueryParameter("code")
val state = parsed.getQueryParameter("state")
code?.let { code ->
state?.let { state ->
mAccount?.completeOAuthFlow(code, state)?.whenComplete { info ->
//displayAndPersistProfile(code, state)
val profile = mAccount?.getProfile(false)?.then(fun (profile: Profile): FxaResult<Unit> {
val accessToken = info.accessToken
val keys = info.keys
val avatar = profile.avatar
val displayName = profile.displayName
val email = profile.email
val uid = profile.uid
val toPass = "{\"accessToken\": \"${accessToken}\", \"keys\": '${keys}', \"avatar\": \"${avatar}\", \"displayName\": \"${displayName}\", \"email\": \"${email}\", \"uid\": \"${uid}\"}"
mToCall = "finishLogin(${toPass})"
this@MainActivity.runOnUiThread({
// Clear the history so that the user can't use the back button to see broken pages
// that were inserted into the history by the login process.
mWebView!!.clearHistory()
// We also reload this here because we need to make sure onPageFinished runs after mToCall has been set.
// We can't guarantee that onPageFinished wasn't already called at this point.
mWebView!!.loadUrl("file:///android_asset/android.html")
})
return FxaResult.fromValue(Unit)
})
}
}
}
}
Log.w("MAIN", "onPageStarted");
}
override fun onPageFinished(url: String) {
Log.w("MAIN", "onPageFinished")
if (mToShare != null) {
Log.w("INTENT", mToShare)
mWebView?.postWebMessage(WebMessage(mToShare), Uri.EMPTY)
mToShare = null
}
if (mToCall != null) {
this@MainActivity.runOnUiThread({
mWebView?.evaluateJavascript(mToCall, fun (value: String) {
mToCall = null
})
})
}
}
override fun onPageError(errorCode: Int, description: String, failingUrl: String) {
Log.w("MAIN", "onPageError " + description)
}
override fun onDownloadRequested(url: String, suggestedFilename: String, mimeType: String, contentLength: Long, contentDisposition: String, userAgent: String) {
Log.w("MAIN", "onDownloadRequested")
}
override fun onExternalPageRequest(url: String) {
Log.w("MAIN", "onExternalPageRequest")
}
}

View File

@ -0,0 +1,97 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="92.5"
android:viewportHeight="92.5">
<group android:translateX="27.75"
android:translateY="28.25">
<path
android:pathData="M18.1313,0.0003C8.1363,0.0003 0.0003,7.9743 0.0003,17.7673C0.0003,18.8133 0.8523,19.6643 1.8983,19.6643L16.2333,19.6643L16.2333,29.1093L11.7773,24.6963C11.0413,23.9613 9.8403,23.9613 9.0653,24.6963C8.3293,25.4323 8.3293,26.6323 9.0653,27.4063L16.7753,35.1093C16.8143,35.1483 16.8533,35.1873 16.9303,35.2253L16.9693,35.2253C17.0083,35.2643 17.0463,35.3033 17.0853,35.3033L17.1243,35.3033C17.1633,35.3423 17.2013,35.3423 17.2403,35.3803L17.2793,35.3803C17.3183,35.4193 17.3953,35.4193 17.4343,35.4583C17.4733,35.4963 17.5503,35.4963 17.5893,35.4963C17.6283,35.4963 17.7053,35.5353 17.7443,35.5353L17.7823,35.5353C17.8213,35.5353 17.8603,35.5353 17.9373,35.5743L18.3253,35.5743C18.3643,35.5743 18.4023,35.5743 18.4803,35.5353L18.5193,35.5353C18.5573,35.5353 18.6353,35.4963 18.6743,35.4963C18.7123,35.4963 18.7903,35.4583 18.8293,35.4193C18.8673,35.3803 18.9453,35.3803 18.9843,35.3423C19.0223,35.3033 19.0613,35.3033 19.1003,35.2643L19.1383,35.2643C19.1773,35.2253 19.2163,35.1873 19.2553,35.1873L19.2933,35.1873C19.3323,35.1483 19.3713,35.1093 19.4483,35.0713L27.1583,27.3673C27.8943,26.6323 27.8943,25.4323 27.1583,24.6583C26.4223,23.9223 25.2213,23.9223 24.4463,24.6583L20.0303,29.1093L20.0303,19.7033L34.3643,19.7033C35.4103,19.7033 36.2633,18.8513 36.2633,17.8063C36.2633,7.9743 28.1273,0.0003 18.1313,0.0003ZM3.9133,15.8713C4.8813,9.0963 10.8863,3.8323 18.1313,3.8323C25.3763,3.8323 31.3423,9.0963 32.3113,15.8713L3.9133,15.8713Z"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startY="2.9809632"
android:startX="25.805717"
android:endY="31.687763"
android:endX="8.569217"
android:type="linear">
<item android:offset="0" android:color="#FFFF980E"/>
<item android:offset="0.21" android:color="#FFFF7139"/>
<item android:offset="0.36" android:color="#FFFF5854"/>
<item android:offset="0.46" android:color="#FFFF4F5E"/>
<item android:offset="0.69" android:color="#FFFF3750"/>
<item android:offset="0.86" android:color="#FFF92261"/>
<item android:offset="1" android:color="#FFF5156C"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M18.1313,0.0003C8.1363,0.0003 0.0003,7.9743 0.0003,17.7673C0.0003,18.8133 0.8523,19.6643 1.8983,19.6643L16.2333,19.6643L16.2333,29.1093L11.7773,24.6963C11.0413,23.9613 9.8403,23.9613 9.0653,24.6963C8.3293,25.4323 8.3293,26.6323 9.0653,27.4063L16.7753,35.1093C16.8143,35.1483 16.8533,35.1873 16.9303,35.2253L16.9693,35.2253C17.0083,35.2643 17.0463,35.3033 17.0853,35.3033L17.1243,35.3033C17.1633,35.3423 17.2013,35.3423 17.2403,35.3803L17.2793,35.3803C17.3183,35.4193 17.3953,35.4193 17.4343,35.4583C17.4733,35.4963 17.5503,35.4963 17.5893,35.4963C17.6283,35.4963 17.7053,35.5353 17.7443,35.5353L17.7823,35.5353C17.8213,35.5353 17.8603,35.5353 17.9373,35.5743L18.3253,35.5743C18.3643,35.5743 18.4023,35.5743 18.4803,35.5353L18.5193,35.5353C18.5573,35.5353 18.6353,35.4963 18.6743,35.4963C18.7123,35.4963 18.7903,35.4583 18.8293,35.4193C18.8673,35.3803 18.9453,35.3803 18.9843,35.3423C19.0223,35.3033 19.0613,35.3033 19.1003,35.2643L19.1383,35.2643C19.1773,35.2253 19.2163,35.1873 19.2553,35.1873L19.2933,35.1873C19.3323,35.1483 19.3713,35.1093 19.4483,35.0713L27.1583,27.3673C27.8943,26.6323 27.8943,25.4323 27.1583,24.6583C26.4223,23.9223 25.2213,23.9223 24.4463,24.6583L20.0303,29.1093L20.0303,19.7033L34.3643,19.7033C35.4103,19.7033 36.2633,18.8513 36.2633,17.8063C36.2633,7.9743 28.1273,0.0003 18.1313,0.0003ZM3.9133,15.8713C4.8813,9.0963 10.8863,3.8323 18.1313,3.8323C25.3763,3.8323 31.3423,9.0963 32.3113,15.8713L3.9133,15.8713Z"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startY="2.9809632"
android:startX="25.805717"
android:endY="31.687763"
android:endX="8.569217"
android:type="linear">
<item android:offset="0" android:color="#CCFFF44F"/>
<item android:offset="0.75" android:color="#00FFF44F"/>
<item android:offset="1" android:color="#00FFF44F"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M20.0303,3.9483C26.3833,4.8003 31.4203,9.6773 32.3113,15.8713L23.8653,15.8713C21.7733,15.8713 20.0683,17.5743 20.0683,19.6643L34.3643,19.6643C35.4103,19.6643 36.2633,18.8133 36.2633,17.7673C36.2633,10.9933 31.4593,7.6643 27.3913,5.7673C23.6333,4.0253 20.0303,3.9483 20.0303,3.9483Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="20.534323"
android:startX="22.366518"
android:endY="7.772023"
android:endX="30.234228"
android:type="linear">
<item android:offset="0" android:color="#FF3A8EE6"/>
<item android:offset="0.24" android:color="#FF5C79F0"/>
<item android:offset="0.63" android:color="#FF9059FF"/>
<item android:offset="1" android:color="#FFC139E6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M32.2333,15.4453C33.5123,16.4903 34.8293,17.4963 36.0693,18.5803C36.1853,18.3483 36.2633,18.0773 36.2633,17.7673C36.2633,10.9933 31.4593,7.6643 27.3913,5.7673C23.6333,4.0253 20.0303,3.9483 20.0303,3.9483C26.2283,4.7613 31.1873,9.4843 32.2333,15.4453Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="8.195093"
android:startX="30.235817"
android:endY="12.836453"
android:endX="26.934916"
android:type="linear">
<item android:offset="0" android:color="#7E6E008B"/>
<item android:offset="0.5" android:color="#00C846CB"/>
<item android:offset="1" android:color="#00C846CB"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M32.0013,15.8713L23.8653,15.8713C21.7733,15.8713 20.0683,17.5743 20.0683,19.6643L34.3643,19.6643C34.9453,19.6643 35.4883,19.3933 35.8373,18.9673C34.5583,17.9223 33.2793,16.9163 32.0013,15.8713Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="18.076923"
android:startX="31.69962"
android:endY="17.594997"
android:endX="23.366179"
android:type="linear">
<item android:offset="0" android:color="#006A2BEA"/>
<item android:offset="0.14" android:color="#006A2BEA"/>
<item android:offset="0.3" android:color="#15662CE6"/>
<item android:offset="0.47" android:color="#2C592FDB"/>
<item android:offset="0.64" android:color="#424534C9"/>
<item android:offset="0.82" android:color="#59283BAF"/>
<item android:offset="0.99" android:color="#7003448D"/>
<item android:offset="1" android:color="#7200458B"/>
</gradient>
</aapt:attr>
</path>
</group>
</vector>

View File

@ -0,0 +1,97 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="92.5"
android:viewportHeight="92.5">
<group android:translateX="27.75"
android:translateY="28.25">
<path
android:pathData="M18.1313,0.0003C8.1363,0.0003 0.0003,7.9743 0.0003,17.7673C0.0003,18.8133 0.8523,19.6643 1.8983,19.6643L16.2333,19.6643L16.2333,29.1093L11.7773,24.6963C11.0413,23.9613 9.8403,23.9613 9.0653,24.6963C8.3293,25.4323 8.3293,26.6323 9.0653,27.4063L16.7753,35.1093C16.8143,35.1483 16.8533,35.1873 16.9303,35.2253L16.9693,35.2253C17.0083,35.2643 17.0463,35.3033 17.0853,35.3033L17.1243,35.3033C17.1633,35.3423 17.2013,35.3423 17.2403,35.3803L17.2793,35.3803C17.3183,35.4193 17.3953,35.4193 17.4343,35.4583C17.4733,35.4963 17.5503,35.4963 17.5893,35.4963C17.6283,35.4963 17.7053,35.5353 17.7443,35.5353L17.7823,35.5353C17.8213,35.5353 17.8603,35.5353 17.9373,35.5743L18.3253,35.5743C18.3643,35.5743 18.4023,35.5743 18.4803,35.5353L18.5193,35.5353C18.5573,35.5353 18.6353,35.4963 18.6743,35.4963C18.7123,35.4963 18.7903,35.4583 18.8293,35.4193C18.8673,35.3803 18.9453,35.3803 18.9843,35.3423C19.0223,35.3033 19.0613,35.3033 19.1003,35.2643L19.1383,35.2643C19.1773,35.2253 19.2163,35.1873 19.2553,35.1873L19.2933,35.1873C19.3323,35.1483 19.3713,35.1093 19.4483,35.0713L27.1583,27.3673C27.8943,26.6323 27.8943,25.4323 27.1583,24.6583C26.4223,23.9223 25.2213,23.9223 24.4463,24.6583L20.0303,29.1093L20.0303,19.7033L34.3643,19.7033C35.4103,19.7033 36.2633,18.8513 36.2633,17.8063C36.2633,7.9743 28.1273,0.0003 18.1313,0.0003ZM3.9133,15.8713C4.8813,9.0963 10.8863,3.8323 18.1313,3.8323C25.3763,3.8323 31.3423,9.0963 32.3113,15.8713L3.9133,15.8713Z"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startY="2.9809632"
android:startX="25.805717"
android:endY="31.687763"
android:endX="8.569217"
android:type="linear">
<item android:offset="0" android:color="#FFFF980E"/>
<item android:offset="0.21" android:color="#FFFF7139"/>
<item android:offset="0.36" android:color="#FFFF5854"/>
<item android:offset="0.46" android:color="#FFFF4F5E"/>
<item android:offset="0.69" android:color="#FFFF3750"/>
<item android:offset="0.86" android:color="#FFF92261"/>
<item android:offset="1" android:color="#FFF5156C"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M18.1313,0.0003C8.1363,0.0003 0.0003,7.9743 0.0003,17.7673C0.0003,18.8133 0.8523,19.6643 1.8983,19.6643L16.2333,19.6643L16.2333,29.1093L11.7773,24.6963C11.0413,23.9613 9.8403,23.9613 9.0653,24.6963C8.3293,25.4323 8.3293,26.6323 9.0653,27.4063L16.7753,35.1093C16.8143,35.1483 16.8533,35.1873 16.9303,35.2253L16.9693,35.2253C17.0083,35.2643 17.0463,35.3033 17.0853,35.3033L17.1243,35.3033C17.1633,35.3423 17.2013,35.3423 17.2403,35.3803L17.2793,35.3803C17.3183,35.4193 17.3953,35.4193 17.4343,35.4583C17.4733,35.4963 17.5503,35.4963 17.5893,35.4963C17.6283,35.4963 17.7053,35.5353 17.7443,35.5353L17.7823,35.5353C17.8213,35.5353 17.8603,35.5353 17.9373,35.5743L18.3253,35.5743C18.3643,35.5743 18.4023,35.5743 18.4803,35.5353L18.5193,35.5353C18.5573,35.5353 18.6353,35.4963 18.6743,35.4963C18.7123,35.4963 18.7903,35.4583 18.8293,35.4193C18.8673,35.3803 18.9453,35.3803 18.9843,35.3423C19.0223,35.3033 19.0613,35.3033 19.1003,35.2643L19.1383,35.2643C19.1773,35.2253 19.2163,35.1873 19.2553,35.1873L19.2933,35.1873C19.3323,35.1483 19.3713,35.1093 19.4483,35.0713L27.1583,27.3673C27.8943,26.6323 27.8943,25.4323 27.1583,24.6583C26.4223,23.9223 25.2213,23.9223 24.4463,24.6583L20.0303,29.1093L20.0303,19.7033L34.3643,19.7033C35.4103,19.7033 36.2633,18.8513 36.2633,17.8063C36.2633,7.9743 28.1273,0.0003 18.1313,0.0003ZM3.9133,15.8713C4.8813,9.0963 10.8863,3.8323 18.1313,3.8323C25.3763,3.8323 31.3423,9.0963 32.3113,15.8713L3.9133,15.8713Z"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startY="2.9809632"
android:startX="25.805717"
android:endY="31.687763"
android:endX="8.569217"
android:type="linear">
<item android:offset="0" android:color="#CCFFF44F"/>
<item android:offset="0.75" android:color="#00FFF44F"/>
<item android:offset="1" android:color="#00FFF44F"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M20.0303,3.9483C26.3833,4.8003 31.4203,9.6773 32.3113,15.8713L23.8653,15.8713C21.7733,15.8713 20.0683,17.5743 20.0683,19.6643L34.3643,19.6643C35.4103,19.6643 36.2633,18.8133 36.2633,17.7673C36.2633,10.9933 31.4593,7.6643 27.3913,5.7673C23.6333,4.0253 20.0303,3.9483 20.0303,3.9483Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="20.534323"
android:startX="22.366518"
android:endY="7.772023"
android:endX="30.234228"
android:type="linear">
<item android:offset="0" android:color="#FF3A8EE6"/>
<item android:offset="0.24" android:color="#FF5C79F0"/>
<item android:offset="0.63" android:color="#FF9059FF"/>
<item android:offset="1" android:color="#FFC139E6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M32.2333,15.4453C33.5123,16.4903 34.8293,17.4963 36.0693,18.5803C36.1853,18.3483 36.2633,18.0773 36.2633,17.7673C36.2633,10.9933 31.4593,7.6643 27.3913,5.7673C23.6333,4.0253 20.0303,3.9483 20.0303,3.9483C26.2283,4.7613 31.1873,9.4843 32.2333,15.4453Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="8.195093"
android:startX="30.235817"
android:endY="12.836453"
android:endX="26.934916"
android:type="linear">
<item android:offset="0" android:color="#7E6E008B"/>
<item android:offset="0.5" android:color="#00C846CB"/>
<item android:offset="1" android:color="#00C846CB"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M32.0013,15.8713L23.8653,15.8713C21.7733,15.8713 20.0683,17.5743 20.0683,19.6643L34.3643,19.6643C34.9453,19.6643 35.4883,19.3933 35.8373,18.9673C34.5583,17.9223 33.2793,16.9163 32.0013,15.8713Z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="18.076923"
android:startX="31.69962"
android:endY="17.594997"
android:endX="23.366179"
android:type="linear">
<item android:offset="0" android:color="#006A2BEA"/>
<item android:offset="0.14" android:color="#006A2BEA"/>
<item android:offset="0.3" android:color="#15662CE6"/>
<item android:offset="0.47" android:color="#2C592FDB"/>
<item android:offset="0.64" android:color="#424534C9"/>
<item android:offset="0.82" android:color="#59283BAF"/>
<item android:offset="0.99" android:color="#7003448D"/>
<item android:offset="1" android:color="#7200458B"/>
</gradient>
</aapt:attr>
</path>
</group>
</vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<im.delight.android.webview.AdvancedWebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.constraint.ConstraintLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Send Android</string>
</resources>

View File

@ -0,0 +1,11 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

32
android/build.gradle Normal file
View File

@ -0,0 +1,32 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.21'
ext.android_components_version = '0.26.0'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.20"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
maven {
url "https://maven.mozilla.org/maven2"
}
jcenter()
maven { url "https://jitpack.io" }
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

13
android/gradle.properties Normal file
View File

@ -0,0 +1,13 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Tue Feb 19 08:34:25 EST 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip

172
android/gradlew vendored Executable file
View File

@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,6 @@
env:
browser: true
parserOptions:
sourceType: module

12
android/pages/error.js Normal file
View File

@ -0,0 +1,12 @@
const html = require('choo/html');
export default function error(_state, _emit) {
return html`
<body>
<div id="white">
<h1>Error</h1>
<p>Sorry, an error occurred.</p>
</div>
</body>
`;
}

74
android/pages/home.js Normal file
View File

@ -0,0 +1,74 @@
const html = require('choo/html');
const { list } = require('../../app/utils');
const archiveTile = require('../../app/ui/archiveTile');
const modal = require('../../app/ui/modal');
const intro = require('../../app/ui/intro');
const assets = require('../../common/assets');
module.exports = function(state, emit) {
function onchange(event) {
event.preventDefault();
const newFiles = Array.from(event.target.files);
emit('addFiles', { files: newFiles });
}
function onclick() {
document.getElementById('file-upload').click();
}
const archives = state.storage.files
.filter(archive => !archive.expired)
.map(archive => archiveTile(state, emit, archive))
.reverse();
let content = '';
let button = html`
<div
class="bg-blue rounded-full m-4 flex items-center justify-center shadow-lg"
style="width: 56px; height: 56px"
onclick="${onclick}"
>
<img src="${assets.get('add.svg')}" />
</div>
`;
if (state.uploading) {
content = html`
<div class="p-6 w-full">${archiveTile.uploading(state, emit)}</div>
`;
button = '';
} else if (state.archive.numFiles > 0) {
content = html`
<section class="p-4 h-full w-full">
${archiveTile.wip(state, emit)}
</section>
`;
button = '';
} else {
content =
archives.length < 1
? intro(state)
: list(
archives,
'list-reset h-full overflow-y-auto w-full p-6',
'mb-3 w-full'
);
}
return html`
<main class="main">
${state.modal && modal(state, emit)} ${content}
<div class="fixed pin-r pin-b">
${button}
<input
id="file-upload"
class="hidden"
type="file"
multiple
onchange="${onchange}"
onclick="${e => e.stopPropagation()}"
/>
</div>
</main>
`;
};

View File

@ -0,0 +1,34 @@
const html = require('choo/html');
import { setFileProtocolWssUrl, getFileProtocolWssUrl } from '../../app/api';
export default function preferences(state, emit) {
const wssURL = getFileProtocolWssUrl();
function updateWssUrl(event) {
state.wssURL = event.target.value;
setFileProtocolWssUrl(state.wssURL);
emit('render');
}
function clickDone(event) {
event.preventDefault();
emit('pushState', '/');
}
return html`
<body>
<div id="white">
<div id="preferences">
<a onclick="${clickDone}" href="#"> done </a>
<dl>
<dt>wss url:</dt>
<dd>
<input type="text" onchange="${updateWssUrl}" value="${wssURL}" />
</dd>
</dl>
</div>
</div>
</body>
`;
}

51
android/pages/share.js Normal file
View File

@ -0,0 +1,51 @@
const html = require('choo/html');
export default function uploadComplete(state, emit) {
const file = state.storage.files[state.storage.files.length - 1];
function onclick(e) {
e.preventDefault();
input.select();
document.execCommand('copy');
input.selectionEnd = input.selectionStart;
copyText.textContent = 'Copied!';
setTimeout(function() {
copyText.textContent = 'Copy link';
}, 2000);
}
function uploadFile(event) {
event.preventDefault();
const target = event.target;
const file = target.files[0];
if (file.size === 0) {
return;
}
emit('pushState', '/upload');
emit('addFiles', { files: [file] });
emit('upload', {});
}
const input = html`
<input id="url" value="${file.url}" readonly="true" />
`;
const copyText = html`
<span>Copy link</span>
`;
return html`<body>
<div id="white">
<div class="card">
<div>The card contents will be here.</div>
<div>Expires after: <span class="expires-after">exp</span></div>
${input}
<div id="copy-link" onclick=${onclick}>
<img id="copy-image" src=${state.getAsset('copy-link.png')} />
${copyText}
</div>
<label id="label" for="input">
<img src=${state.getAsset('cloud-upload.png')} />
</label>
<input id="input" name="input" type="file" onchange=${uploadFile} />
</div>
</body>`;
}

26
android/pages/upload.js Normal file
View File

@ -0,0 +1,26 @@
const html = require('choo/html');
export default function progressBar(state, emit) {
let percent = 0;
if (state.transfer && state.transfer.progress) {
percent = Math.floor(state.transfer.progressRatio * 100);
}
function onclick(e) {
e.preventDefault();
if (state.uploading) {
emit('cancel');
}
emit('pushState', '/');
}
return html`
<body>
<div id="white">
<div class="card">
<div>${percent}%</div>
<span class="progress" style="width: ${percent}%">.</span>
<div class="cancel" onclick="${onclick}">CANCEL</div>
</div>
</div>
</body>
`;
}

1
android/settings.gradle Normal file
View File

@ -0,0 +1 @@
include ':app'

20
android/stores/intents.js Normal file
View File

@ -0,0 +1,20 @@
/* eslint-disable no-console */
export default function intentHandler(state, emitter) {
window.addEventListener(
'message',
event => {
if (typeof event.data !== 'string' || !event.data.startsWith('data:')) {
return;
}
fetch(event.data)
.then(res => res.blob())
.then(blob => {
emitter.emit('addFiles', { files: [blob] });
emitter.emit('upload', {});
})
.catch(e => console.error('ERROR ' + e + ' ' + e.stack));
},
false
);
}

41
android/stores/state.js Normal file
View File

@ -0,0 +1,41 @@
/* eslint-disable no-console */
import User from '../user';
import storage from '../../app/storage';
export default function initialState(state, emitter) {
const files = [];
Object.assign(state, {
prefix: '/android_asset',
user: new User(storage),
getAsset(name) {
return `${state.prefix}/${name}`;
},
raven: {
captureException: e => {
console.error('ERROR ' + e + ' ' + e.stack);
}
},
storage: {
files,
remove: function(fileId) {
console.log('REMOVE FILEID', fileId);
},
writeFile: function(file) {
console.log('WRITEFILE', file);
},
addFile: function(file) {
console.log('addfile' + JSON.stringify(file));
files.push(file);
emitter.emit('pushState', `/share/${file.id}`);
},
totalUploads: 0
},
transfer: null,
uploading: false,
settingPassword: false,
passwordSetError: null,
route: '/'
});
}

26
android/user.js Normal file
View File

@ -0,0 +1,26 @@
/* global Android */
import User from '../app/user';
import { deriveFileListKey } from '../app/fxa';
export default class AndroidUser extends User {
constructor(storage, limits) {
super(storage, limits);
}
async login() {
Android.beginOAuthFlow();
}
async finishLogin(accountInfo) {
const jwks = JSON.parse(accountInfo.keys);
const ikm = jwks['https://identity.mozilla.com/apps/send'].k;
const profile = {
displayName: accountInfo.displayName,
email: accountInfo.email,
avatar: accountInfo.avatar,
access_token: accountInfo.accessToken
};
profile.fileListKey = await deriveFileListKey(ikm);
this.info = profile;
}
}

View File

@ -1,16 +1,49 @@
import { arrayToB64, b64ToArray } from './utils'; import { arrayToB64, b64ToArray, delay } from './utils';
import { ECE_RECORD_SIZE } from './ece';
function post(obj) { let fileProtocolWssUrl = null;
try {
fileProtocolWssUrl = localStorage.getItem('wssURL');
} catch (e) {
// NOOP
}
if (!fileProtocolWssUrl) {
fileProtocolWssUrl = 'wss://send2.dev.lcip.org/api/ws';
}
export function setFileProtocolWssUrl(url) {
localStorage && localStorage.setItem('wssURL', url);
fileProtocolWssUrl = url;
}
export function getFileProtocolWssUrl() {
return fileProtocolWssUrl;
}
let apiUrlPrefix = '';
export function getApiUrl(path) {
return apiUrlPrefix + path;
}
export function setApiUrlPrefix(prefix) {
apiUrlPrefix = prefix;
}
function post(obj, bearerToken) {
const h = {
'Content-Type': 'application/json'
};
if (bearerToken) {
h['Authentication'] = `Bearer ${bearerToken}`;
}
return { return {
method: 'POST', method: 'POST',
headers: new Headers({ headers: new Headers(h),
'Content-Type': 'application/json'
}),
body: JSON.stringify(obj) body: JSON.stringify(obj)
}; };
} }
function parseNonce(header) { export function parseNonce(header) {
header = header || ''; header = header || '';
return header.split(' ')[1]; return header.split(' ')[1];
} }
@ -38,33 +71,44 @@ async function fetchWithAuthAndRetry(url, params, keychain) {
} }
export async function del(id, owner_token) { export async function del(id, owner_token) {
const response = await fetch(`/api/delete/${id}`, post({ owner_token })); const response = await fetch(
getApiUrl(`/api/delete/${id}`),
post({ owner_token })
);
return response.ok; return response.ok;
} }
export async function setParams(id, owner_token, params) { export async function setParams(id, owner_token, bearerToken, params) {
const response = await fetch( const response = await fetch(
`/api/params/${id}`, getApiUrl(`/api/params/${id}`),
post({ post(
owner_token, {
dlimit: params.dlimit owner_token,
}) dlimit: params.dlimit
},
bearerToken
)
); );
return response.ok; return response.ok;
} }
export async function fileInfo(id, owner_token) { export async function fileInfo(id, owner_token) {
const response = await fetch(`/api/info/${id}`, post({ owner_token })); const response = await fetch(
getApiUrl(`/api/info/${id}`),
post({ owner_token })
);
if (response.ok) { if (response.ok) {
const obj = await response.json(); const obj = await response.json();
return obj; return obj;
} }
throw new Error(response.status); throw new Error(response.status);
} }
export async function metadata(id, keychain) { export async function metadata(id, keychain) {
const result = await fetchWithAuthAndRetry( const result = await fetchWithAuthAndRetry(
`/api/metadata/${id}`, getApiUrl(`/api/metadata/${id}`),
{ method: 'GET' }, { method: 'GET' },
keychain keychain
); );
@ -72,11 +116,12 @@ export async function metadata(id, keychain) {
const data = await result.response.json(); const data = await result.response.json();
const meta = await keychain.decryptMetadata(b64ToArray(data.metadata)); const meta = await keychain.decryptMetadata(b64ToArray(data.metadata));
return { return {
size: data.size, size: meta.size,
ttl: data.ttl, ttl: data.ttl,
iv: meta.iv, iv: meta.iv,
name: meta.name, name: meta.name,
type: meta.type type: meta.type,
manifest: meta.manifest
}; };
} }
throw new Error(result.response.status); throw new Error(result.response.status);
@ -85,58 +130,188 @@ export async function metadata(id, keychain) {
export async function setPassword(id, owner_token, keychain) { export async function setPassword(id, owner_token, keychain) {
const auth = await keychain.authKeyB64(); const auth = await keychain.authKeyB64();
const response = await fetch( const response = await fetch(
`/api/password/${id}`, getApiUrl(`/api/password/${id}`),
post({ owner_token, auth }) post({ owner_token, auth })
); );
return response.ok; return response.ok;
} }
export function uploadFile( function asyncInitWebSocket(server) {
return new Promise(resolve => {
const ws = new WebSocket(server);
ws.onopen = () => {
resolve(ws);
};
});
}
function listenForResponse(ws, canceller) {
return new Promise((resolve, reject) => {
function handleMessage(msg) {
try {
const response = JSON.parse(msg.data);
if (response.error) {
throw new Error(response.error);
} else {
resolve(response);
}
} catch (e) {
ws.close();
canceller.cancelled = true;
canceller.error = e;
reject(e);
}
}
ws.addEventListener('message', handleMessage, { once: true });
});
}
async function upload(
stream,
metadata,
verifierB64,
timeLimit,
dlimit,
bearerToken,
onprogress,
canceller
) {
const host = window.location.hostname;
const port = window.location.port;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const endpoint =
window.location.protocol === 'file:'
? fileProtocolWssUrl
: `${protocol}//${host}${port ? ':' : ''}${port}/api/ws`;
const ws = await asyncInitWebSocket(endpoint);
try {
const metadataHeader = arrayToB64(new Uint8Array(metadata));
const fileMeta = {
fileMetadata: metadataHeader,
authorization: `send-v1 ${verifierB64}`,
bearer: bearerToken,
timeLimit,
dlimit
};
const uploadInfoResponse = listenForResponse(ws, canceller);
ws.send(JSON.stringify(fileMeta));
const uploadInfo = await uploadInfoResponse;
const completedResponse = listenForResponse(ws, canceller);
const reader = stream.getReader();
let state = await reader.read();
let size = 0;
while (!state.done) {
const buf = state.value;
if (canceller.cancelled) {
throw canceller.error;
}
ws.send(buf);
onprogress(size);
size += buf.length;
state = await reader.read();
while (ws.bufferedAmount > ECE_RECORD_SIZE * 2) {
await delay();
}
}
const footer = new Uint8Array([0]);
ws.send(footer);
await completedResponse;
ws.close();
return uploadInfo;
} catch (e) {
ws.close(4000);
throw e;
}
}
export function uploadWs(
encrypted, encrypted,
metadata, metadata,
verifierB64, verifierB64,
keychain, timeLimit,
dlimit,
bearerToken,
onprogress onprogress
) { ) {
const xhr = new XMLHttpRequest(); const canceller = { cancelled: false };
const upload = {
return {
cancel: function() { cancel: function() {
xhr.abort(); canceller.error = new Error(0);
canceller.cancelled = true;
}, },
result: new Promise(function(resolve, reject) {
xhr.addEventListener('loadend', function() { result: upload(
const authHeader = xhr.getResponseHeader('WWW-Authenticate'); encrypted,
if (authHeader) { metadata,
keychain.nonce = parseNonce(authHeader); verifierB64,
} timeLimit,
if (xhr.status === 200) { dlimit,
const responseObj = JSON.parse(xhr.responseText); bearerToken,
return resolve({ onprogress,
url: responseObj.url, canceller
id: responseObj.id, )
ownerToken: responseObj.owner
});
}
reject(new Error(xhr.status));
});
})
}; };
const dataView = new DataView(encrypted);
const blob = new Blob([dataView], { type: 'application/octet-stream' });
const fd = new FormData();
fd.append('data', blob);
xhr.upload.addEventListener('progress', function(event) {
if (event.lengthComputable) {
onprogress([event.loaded, event.total]);
}
});
xhr.open('post', '/api/upload', true);
xhr.setRequestHeader('X-File-Metadata', arrayToB64(new Uint8Array(metadata)));
xhr.setRequestHeader('Authorization', `send-v1 ${verifierB64}`);
xhr.send(fd);
return upload;
} }
////////////////////////
async function downloadS(id, keychain, signal) {
const auth = await keychain.authHeader();
const response = await fetch(getApiUrl(`/api/download/${id}`), {
signal: signal,
method: 'GET',
headers: { Authorization: auth }
});
const authHeader = response.headers.get('WWW-Authenticate');
if (authHeader) {
keychain.nonce = parseNonce(authHeader);
}
if (response.status !== 200) {
throw new Error(response.status);
}
return response.body;
}
async function tryDownloadStream(id, keychain, signal, tries = 1) {
try {
const result = await downloadS(id, keychain, signal);
return result;
} catch (e) {
if (e.message === '401' && --tries > 0) {
return tryDownloadStream(id, keychain, signal, tries);
}
if (e.name === 'AbortError') {
throw new Error('0');
}
throw e;
}
}
export function downloadStream(id, keychain) {
const controller = new AbortController();
function cancel() {
controller.abort();
}
return {
cancel,
result: tryDownloadStream(id, keychain, controller.signal, 2)
};
}
//////////////////
function download(id, keychain, onprogress, canceller) { function download(id, keychain, onprogress, canceller) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
canceller.oncancel = function() { canceller.oncancel = function() {
@ -154,11 +329,7 @@ function download(id, keychain, onprogress, canceller) {
} }
const blob = new Blob([xhr.response]); const blob = new Blob([xhr.response]);
const fileReader = new FileReader(); resolve(blob);
fileReader.readAsArrayBuffer(blob);
fileReader.onload = function() {
resolve(this.result);
};
}); });
xhr.addEventListener('progress', function(event) { xhr.addEventListener('progress', function(event) {
@ -167,7 +338,7 @@ function download(id, keychain, onprogress, canceller) {
} }
}); });
const auth = await keychain.authHeader(); const auth = await keychain.authHeader();
xhr.open('get', `/api/download/${id}`); xhr.open('get', getApiUrl(`/api/download/blob/${id}`));
xhr.setRequestHeader('Authorization', auth); xhr.setRequestHeader('Authorization', auth);
xhr.responseType = 'blob'; xhr.responseType = 'blob';
xhr.send(); xhr.send();
@ -199,3 +370,45 @@ export function downloadFile(id, keychain, onprogress) {
result: tryDownload(id, keychain, onprogress, canceller, 2) result: tryDownload(id, keychain, onprogress, canceller, 2)
}; };
} }
export async function getFileList(bearerToken, kid) {
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
const response = await fetch(getApiUrl(`/api/filelist/${kid}`), { headers });
if (response.ok) {
const encrypted = await response.blob();
return encrypted;
}
throw new Error(response.status);
}
export async function setFileList(bearerToken, kid, data) {
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
const response = await fetch(getApiUrl(`/api/filelist/${kid}`), {
headers,
method: 'POST',
body: data
});
return response.ok;
}
export function sendMetrics(blob) {
if (!navigator.sendBeacon) {
return;
}
try {
navigator.sendBeacon(getApiUrl('/api/metrics'), blob);
} catch (e) {
console.error(e);
}
}
export async function getConstants() {
const response = await fetch(getApiUrl('/config'));
if (response.ok) {
const obj = await response.json();
return obj;
}
throw new Error(response.status);
}

83
app/archive.js Normal file
View File

@ -0,0 +1,83 @@
import { blobStream, concatStream } from './streams';
function isDupe(newFile, array) {
for (const file of array) {
if (
newFile.name === file.name &&
newFile.size === file.size &&
newFile.lastModified === file.lastModified
) {
return true;
}
}
return false;
}
export default class Archive {
constructor(files = [], defaultTimeLimit = 86400) {
this.files = Array.from(files);
this.defaultTimeLimit = defaultTimeLimit;
this.timeLimit = defaultTimeLimit;
this.dlimit = 1;
this.password = null;
}
get name() {
return this.files.length > 1 ? 'Send-Archive.zip' : this.files[0].name;
}
get type() {
return this.files.length > 1 ? 'send-archive' : this.files[0].type;
}
get size() {
return this.files.reduce((total, file) => total + file.size, 0);
}
get numFiles() {
return this.files.length;
}
get manifest() {
return {
files: this.files.map(file => ({
name: file.name,
size: file.size,
type: file.type
}))
};
}
get stream() {
return concatStream(this.files.map(file => blobStream(file)));
}
addFiles(files, maxSize, maxFiles) {
if (this.files.length + files.length > maxFiles) {
throw new Error('tooManyFiles');
}
const newFiles = files.filter(
file => file.size > 0 && !isDupe(file, this.files)
);
const newSize = newFiles.reduce((total, file) => total + file.size, 0);
if (this.size + newSize > maxSize) {
throw new Error('fileTooBig');
}
this.files = this.files.concat(newFiles);
return true;
}
remove(file) {
const index = this.files.indexOf(file);
if (index > -1) {
this.files.splice(index, 1);
}
}
clear() {
this.files = [];
this.dlimit = 1;
this.timeLimit = this.defaultTimeLimit;
this.password = null;
}
}

View File

@ -1,268 +0,0 @@
:root {
--pageBGColor: #fff;
--primaryControlBGColor: #0297f8;
--primaryControlFGColor: #fff;
--primaryControlHoverColor: #0287e8;
--inputTextColor: #737373;
--errorColor: #d70022;
--linkColor: #0094fb;
--textColor: #0c0c0d;
--lightTextColor: #737373;
--successControlBGColor: #05a700;
--successControlFGColor: #fff;
}
html {
background: url('../assets/send_bg.svg');
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'segoe ui',
'helvetica neue', helvetica, ubuntu, roboto, noto, arial, sans-serif;
font-weight: 200;
background-size: 110%;
background-repeat: no-repeat;
background-position: center top;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'segoe ui',
'helvetica neue', helvetica, ubuntu, roboto, noto, arial, sans-serif;
display: flex;
flex-direction: column;
margin: 0;
min-height: 100vh;
}
input,
select,
textarea,
button {
font-family: inherit;
margin: 0;
}
a {
text-decoration: none;
}
.main {
flex: auto;
max-width: 650px;
margin: 0 auto;
padding: 0 20px;
box-sizing: border-box;
width: 96%;
}
.noscript {
text-align: center;
border: 3px solid var(--errorColor);
border-radius: 6px;
}
.btn {
font-size: 15px;
font-weight: 500;
color: var(--primaryControlFGColor);
cursor: pointer;
text-align: center;
background: var(--primaryControlBGColor);
border: 1px solid var(--primaryControlBGColor);
border-radius: 5px;
}
.btn:hover {
background-color: var(--primaryControlHoverColor);
}
.btn--cancel {
color: var(--errorColor);
background: var(--pageBGColor);
font-size: 15px;
border: 0;
cursor: pointer;
text-decoration: underline;
}
.btn--cancel:disabled {
text-decoration: none;
cursor: auto;
}
.btn--cancel:hover {
background-color: var(--pageBGColor);
}
.input {
flex: 2 0 auto;
border: 1px solid var(--primaryControlBGColor);
border-radius: 6px 0 0 6px;
font-size: 20px;
color: var(--inputTextColor);
font-family: 'SF Pro Text', sans-serif;
letter-spacing: 0;
line-height: 23px;
font-weight: 300;
height: 46px;
padding-left: 10px;
padding-right: 10px;
}
.input--error {
border-color: var(--errorColor);
}
.input--noBtn {
border-radius: 6px;
}
.inputBtn {
flex: auto;
background: var(--primaryControlBGColor);
border-radius: 0 6px 6px 0;
border: 1px solid var(--primaryControlBGColor);
color: var(--primaryControlFGColor);
cursor: pointer;
/* Force flat button look */
/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
appearance: none;
font-size: 15px;
padding-bottom: 3px;
padding-left: 10px;
padding-right: 10px;
white-space: nowrap;
}
.inputBtn:disabled {
cursor: auto;
}
.inputBtn:hover {
background-color: var(--primaryControlHoverColor);
}
.inputBtn--hidden {
display: none;
}
.cursor--pointer {
cursor: pointer;
}
.link {
color: var(--linkColor);
text-decoration: none;
}
.link:focus,
.link:active,
.link:hover {
color: var(--primaryControlHoverColor);
}
.link--action {
text-decoration: underline;
text-align: center;
}
.page {
margin: 0 auto 30px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
}
.progressSection {
margin: 0 auto;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
font-size: 15px;
}
.progressSection__text {
color: var(--lightTextColor);
letter-spacing: -0.4px;
margin-top: 24px;
margin-bottom: 74px;
}
.effect--fadeOut {
opacity: 0;
animation: fadeout 200ms linear;
}
@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.effect--fadeIn {
opacity: 1;
animation: fadein 200ms linear;
}
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.error {
color: var(--errorColor);
}
.title {
font-size: 33px;
line-height: 40px;
margin: 20px auto;
text-align: center;
max-width: 520px;
font-family: 'SF Pro Text', sans-serif;
word-wrap: break-word;
}
.description {
font-size: 15px;
line-height: 23px;
max-width: 630px;
text-align: center;
margin: 0 auto 60px;
color: var(--textColor);
width: 92%;
}
@media (max-device-width: 768px), (max-width: 768px) {
.description {
margin: 0 auto 25px;
}
}
@media (max-device-width: 520px), (max-width: 520px) {
.input {
font-size: 22px;
padding: 10px 10px;
border-radius: 6px 6px 0 0;
}
.inputBtn {
border-radius: 0 0 6px 6px;
flex: 0 1 65px;
}
.input--noBtn {
border-radius: 6px;
}
}

103
app/capabilities.js Normal file
View File

@ -0,0 +1,103 @@
/* global AUTH_CONFIG */
import { browserName } from './utils';
async function checkCrypto() {
try {
const key = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
);
await crypto.subtle.exportKey('raw', key);
await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: crypto.getRandomValues(new Uint8Array(12)),
tagLength: 128
},
key,
new ArrayBuffer(8)
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'PBKDF2',
false,
['deriveKey']
);
await crypto.subtle.importKey(
'raw',
crypto.getRandomValues(new Uint8Array(16)),
'HKDF',
false,
['deriveKey']
);
await crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256'
},
true,
['deriveBits']
);
return true;
} catch (err) {
try {
window.asmCrypto = await import('asmcrypto.js');
await import('@dannycoates/webcrypto-liner/build/shim');
return true;
} catch (e) {
return false;
}
}
}
function checkStreams() {
try {
new ReadableStream({
pull() {}
});
return true;
} catch (e) {
return false;
}
}
async function polyfillStreams() {
try {
await import('@mattiasbuelens/web-streams-polyfill');
return true;
} catch (e) {
return false;
}
}
export default async function getCapabilities() {
const serviceWorker =
'serviceWorker' in navigator && browserName() !== 'edge';
let crypto = await checkCrypto();
const nativeStreams = checkStreams();
let polyStreams = false;
if (!nativeStreams) {
polyStreams = await polyfillStreams();
}
let account = typeof AUTH_CONFIG !== 'undefined';
try {
account = account && !!localStorage;
} catch (e) {
account = false;
}
return {
account,
crypto,
serviceWorker,
streamUpload: nativeStreams || polyStreams,
streamDownload:
nativeStreams && serviceWorker && browserName() !== 'safari',
multifile: nativeStreams || polyStreams
};
}

299
app/controller.js Normal file
View File

@ -0,0 +1,299 @@
import FileSender from './fileSender';
import FileReceiver from './fileReceiver';
import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
import * as metrics from './metrics';
import { bytes } from './utils';
import okDialog from './ui/okDialog';
import copyDialog from './ui/copyDialog';
import signupDialog from './ui/signupDialog';
export default function(state, emitter) {
let lastRender = 0;
let updateTitle = false;
function render() {
emitter.emit('render');
}
async function checkFiles() {
const changes = await state.user.syncFileList();
const rerender = changes.incoming || changes.downloadCount;
if (rerender) {
render();
}
}
function updateProgress() {
if (updateTitle) {
emitter.emit('DOMTitleChange', percent(state.transfer.progressRatio));
}
render();
}
emitter.on('DOMContentLoaded', () => {
document.addEventListener('blur', () => (updateTitle = true));
document.addEventListener('focus', () => {
updateTitle = false;
emitter.emit('DOMTitleChange', 'Firefox Send');
});
checkFiles();
});
emitter.on('render', () => {
lastRender = Date.now();
});
emitter.on('login', email => {
state.user.login(email);
});
emitter.on('logout', () => {
state.user.logout();
metrics.loggedOut({ trigger: 'button' });
emitter.emit('pushState', '/');
});
emitter.on('removeUpload', file => {
state.archive.remove(file);
if (state.archive.numFiles === 0) {
state.archive.clear();
}
render();
});
emitter.on('delete', async ownedFile => {
try {
metrics.deletedUpload({
size: ownedFile.size,
time: ownedFile.time,
speed: ownedFile.speed,
type: ownedFile.type,
ttl: ownedFile.expiresAt - Date.now(),
location
});
state.storage.remove(ownedFile.id);
await ownedFile.del();
} catch (e) {
state.raven.captureException(e);
}
render();
});
emitter.on('cancel', () => {
state.transfer.cancel();
});
emitter.on('addFiles', async ({ files }) => {
if (files.length < 1) {
return;
}
const maxSize = state.user.maxSize;
try {
state.archive.addFiles(
files,
maxSize,
state.LIMITS.MAX_FILES_PER_ARCHIVE
);
} catch (e) {
if (e.message === 'fileTooBig' && maxSize < state.LIMITS.MAX_FILE_SIZE) {
return emitter.emit('signup-cta', 'size');
}
state.modal = okDialog(
state.translate(e.message, {
size: bytes(maxSize),
count: state.LIMITS.MAX_FILES_PER_ARCHIVE
})
);
}
render();
});
emitter.on('signup-cta', source => {
const query = state.query;
state.user.startAuthFlow(source, {
campaign: query.utm_campaign,
content: query.utm_content,
medium: query.utm_medium,
source: query.utm_source,
term: query.utm_term
});
state.modal = signupDialog(source);
render();
});
emitter.on('authenticate', async (code, oauthState) => {
try {
await state.user.finishLogin(code, oauthState);
await state.user.syncFileList();
emitter.emit('replaceState', '/');
} catch (e) {
emitter.emit('replaceState', '/error');
setTimeout(render);
}
});
emitter.on('upload', async () => {
if (state.storage.files.length >= state.LIMITS.MAX_ARCHIVES_PER_USER) {
state.modal = okDialog(
state.translate('tooManyArchives', {
count: state.LIMITS.MAX_ARCHIVES_PER_USER
})
);
return render();
}
const archive = state.archive;
const sender = new FileSender();
sender.on('progress', updateProgress);
sender.on('encrypting', render);
sender.on('complete', render);
state.transfer = sender;
state.uploading = true;
render();
const links = openLinksInNewTab();
await delay(200);
const start = Date.now();
try {
const ownedFile = await sender.upload(archive, state.user.bearerToken);
state.storage.totalUploads += 1;
const duration = Date.now() - start;
metrics.completedUpload(archive, duration);
state.storage.addFile(ownedFile);
// TODO integrate password into /upload request
if (archive.password) {
emitter.emit('password', {
password: archive.password,
file: ownedFile
});
}
state.modal = copyDialog(ownedFile.name, ownedFile.url);
} catch (err) {
if (err.message === '0') {
//cancelled. do nothing
const duration = Date.now() - start;
metrics.cancelledUpload(archive, duration);
render();
} else {
// eslint-disable-next-line no-console
console.error(err);
state.raven.captureException(err);
metrics.stoppedUpload(archive);
emitter.emit('pushState', '/error');
}
} finally {
openLinksInNewTab(links, false);
archive.clear();
state.uploading = false;
state.transfer = null;
await state.user.syncFileList();
render();
}
});
emitter.on('password', async ({ password, file }) => {
try {
state.settingPassword = true;
render();
await file.setPassword(password);
state.storage.writeFile(file);
await delay(1000);
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
state.passwordSetError = err;
} finally {
state.settingPassword = false;
}
render();
});
emitter.on('getMetadata', async () => {
const file = state.fileInfo;
const receiver = new FileReceiver(file);
try {
await receiver.getMetadata();
state.transfer = receiver;
} catch (e) {
if (e.message === '401' || e.message === '404') {
file.password = null;
if (!file.requiresPassword) {
return emitter.emit('pushState', '/404');
}
}
}
render();
});
emitter.on('download', async file => {
state.transfer.on('progress', updateProgress);
state.transfer.on('decrypting', render);
state.transfer.on('complete', render);
const links = openLinksInNewTab();
const size = file.size;
const start = Date.now();
try {
const dl = state.transfer.download({
stream: state.capabilities.streamDownload
});
render();
await dl;
state.storage.totalDownloads += 1;
const duration = Date.now() - start;
metrics.completedDownload({
size,
duration,
password_protected: file.requiresPassword
});
} catch (err) {
if (err.message === '0') {
// download cancelled
state.transfer.reset();
render();
} else {
// eslint-disable-next-line no-console
state.transfer = null;
const location = err.message === '404' ? '/404' : '/error';
if (location === '/error') {
state.raven.captureException(err);
const duration = Date.now() - start;
metrics.stoppedDownload({
size,
duration,
password_protected: file.requiresPassword
});
}
emitter.emit('pushState', location);
}
} finally {
openLinksInNewTab(links, false);
}
});
emitter.on('copy', ({ url }) => {
copyToClipboard(url);
// metrics.copiedLink({ location });
});
setInterval(() => {
// poll for updates of the upload list
if (!state.modal && state.route === '/') {
checkFiles();
}
}, 2 * 60 * 1000);
setInterval(() => {
// poll for rerendering the file list countdown timers
if (
!state.modal &&
state.route === '/' &&
state.storage.files.length > 0 &&
Date.now() - lastRender > 30000
) {
render();
}
}, 60000);
}

View File

@ -1,6 +1,3 @@
/* global MAXFILESIZE */
const { bytes } = require('./utils');
export default function(state, emitter) { export default function(state, emitter) {
emitter.on('DOMContentLoaded', () => { emitter.on('DOMContentLoaded', () => {
document.body.addEventListener('dragover', event => { document.body.addEventListener('dragover', event => {
@ -9,29 +6,16 @@ export default function(state, emitter) {
} }
}); });
document.body.addEventListener('drop', event => { document.body.addEventListener('drop', event => {
if (state.route === '/' && !state.uploading) { if (
state.route === '/' &&
!state.uploading &&
event.dataTransfer &&
event.dataTransfer.files
) {
event.preventDefault(); event.preventDefault();
document emitter.emit('addFiles', {
.querySelector('.uploadArea') files: Array.from(event.dataTransfer.files)
.classList.remove('uploadArea--dragging'); });
const target = event.dataTransfer;
if (target.files.length === 0) {
return;
}
if (target.files.length > 1) {
// eslint-disable-next-line no-alert
return alert(state.translate('uploadPageMultipleFilesAlert'));
}
const file = target.files[0];
if (file.size === 0) {
return;
}
if (file.size > MAXFILESIZE) {
// eslint-disable-next-line no-alert
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
return;
}
emitter.emit('upload', { file, type: 'drop' });
} }
}); });
}); });

310
app/ece.js Normal file
View File

@ -0,0 +1,310 @@
import 'buffer';
import { transformStream } from './streams';
const NONCE_LENGTH = 12;
const TAG_LENGTH = 16;
const KEY_LENGTH = 16;
const MODE_ENCRYPT = 'encrypt';
const MODE_DECRYPT = 'decrypt';
export const ECE_RECORD_SIZE = 1024 * 64;
const encoder = new TextEncoder();
function generateSalt(len) {
const randSalt = new Uint8Array(len);
crypto.getRandomValues(randSalt);
return randSalt.buffer;
}
class ECETransformer {
constructor(mode, ikm, rs, salt) {
this.mode = mode;
this.prevChunk;
this.seq = 0;
this.firstchunk = true;
this.rs = rs;
this.ikm = ikm.buffer;
this.salt = salt;
}
async generateKey() {
const inputKey = await crypto.subtle.importKey(
'raw',
this.ikm,
'HKDF',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: this.salt,
info: encoder.encode('Content-Encoding: aes128gcm\0'),
hash: 'SHA-256'
},
inputKey,
{
name: 'AES-GCM',
length: 128
},
true, // Edge polyfill requires key to be extractable to encrypt :/
['encrypt', 'decrypt']
);
}
async generateNonceBase() {
const inputKey = await crypto.subtle.importKey(
'raw',
this.ikm,
'HKDF',
false,
['deriveKey']
);
const base = await crypto.subtle.exportKey(
'raw',
await crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: this.salt,
info: encoder.encode('Content-Encoding: nonce\0'),
hash: 'SHA-256'
},
inputKey,
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
)
);
return Buffer.from(base.slice(0, NONCE_LENGTH));
}
generateNonce(seq) {
if (seq > 0xffffffff) {
throw new Error('record sequence number exceeds limit');
}
const nonce = Buffer.from(this.nonceBase);
const m = nonce.readUIntBE(nonce.length - 4, 4);
const xor = (m ^ seq) >>> 0; //forces unsigned int xor
nonce.writeUIntBE(xor, nonce.length - 4, 4);
return nonce;
}
pad(data, isLast) {
const len = data.length;
if (len + TAG_LENGTH >= this.rs) {
throw new Error('data too large for record size');
}
if (isLast) {
const padding = Buffer.alloc(1);
padding.writeUInt8(2, 0);
return Buffer.concat([data, padding]);
} else {
const padding = Buffer.alloc(this.rs - len - TAG_LENGTH);
padding.fill(0);
padding.writeUInt8(1, 0);
return Buffer.concat([data, padding]);
}
}
unpad(data, isLast) {
for (let i = data.length - 1; i >= 0; i--) {
if (data[i]) {
if (isLast) {
if (data[i] !== 2) {
throw new Error('delimiter of final record is not 2');
}
} else {
if (data[i] !== 1) {
throw new Error('delimiter of not final record is not 1');
}
}
return data.slice(0, i);
}
}
throw new Error('no delimiter found');
}
createHeader() {
const nums = Buffer.alloc(5);
nums.writeUIntBE(this.rs, 0, 4);
nums.writeUIntBE(0, 4, 1);
return Buffer.concat([Buffer.from(this.salt), nums]);
}
readHeader(buffer) {
if (buffer.length < 21) {
throw new Error('chunk too small for reading header');
}
const header = {};
header.salt = buffer.buffer.slice(0, KEY_LENGTH);
header.rs = buffer.readUIntBE(KEY_LENGTH, 4);
const idlen = buffer.readUInt8(KEY_LENGTH + 4);
header.length = idlen + KEY_LENGTH + 5;
return header;
}
async encryptRecord(buffer, seq, isLast) {
const nonce = this.generateNonce(seq);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce },
this.key,
this.pad(buffer, isLast)
);
return Buffer.from(encrypted);
}
async decryptRecord(buffer, seq, isLast) {
const nonce = this.generateNonce(seq);
const data = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: nonce,
tagLength: 128
},
this.key,
buffer
);
return this.unpad(Buffer.from(data), isLast);
}
async start(controller) {
if (this.mode === MODE_ENCRYPT) {
this.key = await this.generateKey();
this.nonceBase = await this.generateNonceBase();
controller.enqueue(this.createHeader());
} else if (this.mode !== MODE_DECRYPT) {
throw new Error('mode must be either encrypt or decrypt');
}
}
async transformPrevChunk(isLast, controller) {
if (this.mode === MODE_ENCRYPT) {
controller.enqueue(
await this.encryptRecord(this.prevChunk, this.seq, isLast)
);
this.seq++;
} else {
if (this.seq === 0) {
//the first chunk during decryption contains only the header
const header = this.readHeader(this.prevChunk);
this.salt = header.salt;
this.rs = header.rs;
this.key = await this.generateKey();
this.nonceBase = await this.generateNonceBase();
} else {
controller.enqueue(
await this.decryptRecord(this.prevChunk, this.seq - 1, isLast)
);
}
this.seq++;
}
}
async transform(chunk, controller) {
if (!this.firstchunk) {
await this.transformPrevChunk(false, controller);
}
this.firstchunk = false;
this.prevChunk = Buffer.from(chunk.buffer);
}
async flush(controller) {
//console.log('ece stream ends')
if (this.prevChunk) {
await this.transformPrevChunk(true, controller);
}
}
}
class StreamSlicer {
constructor(rs, mode) {
this.mode = mode;
this.rs = rs;
this.chunkSize = mode === MODE_ENCRYPT ? rs - 17 : 21;
this.partialChunk = new Uint8Array(this.chunkSize); //where partial chunks are saved
this.offset = 0;
}
send(buf, controller) {
controller.enqueue(buf);
if (this.chunkSize === 21 && this.mode === MODE_DECRYPT) {
this.chunkSize = this.rs;
}
this.partialChunk = new Uint8Array(this.chunkSize);
this.offset = 0;
}
//reslice input into record sized chunks
transform(chunk, controller) {
//console.log('Received chunk with %d bytes.', chunk.byteLength)
let i = 0;
if (this.offset > 0) {
const len = Math.min(chunk.byteLength, this.chunkSize - this.offset);
this.partialChunk.set(chunk.slice(0, len), this.offset);
this.offset += len;
i += len;
if (this.offset === this.chunkSize) {
this.send(this.partialChunk, controller);
}
}
while (i < chunk.byteLength) {
const remainingBytes = chunk.byteLength - i;
if (remainingBytes >= this.chunkSize) {
const record = chunk.slice(i, i + this.chunkSize);
i += this.chunkSize;
this.send(record, controller);
} else {
const end = chunk.slice(i, i + remainingBytes);
i += end.byteLength;
this.partialChunk.set(end);
this.offset = end.byteLength;
}
}
}
flush(controller) {
if (this.offset > 0) {
controller.enqueue(this.partialChunk.slice(0, this.offset));
}
}
}
/*
input: a ReadableStream containing data to be transformed
key: Uint8Array containing key of size KEY_LENGTH
rs: int containing record size, optional
salt: ArrayBuffer containing salt of KEY_LENGTH length, optional
*/
export function encryptStream(
input,
key,
rs = ECE_RECORD_SIZE,
salt = generateSalt(KEY_LENGTH)
) {
const mode = 'encrypt';
const inputStream = transformStream(input, new StreamSlicer(rs, mode));
return transformStream(inputStream, new ECETransformer(mode, key, rs, salt));
}
/*
input: a ReadableStream containing data to be transformed
key: Uint8Array containing key of size KEY_LENGTH
rs: int containing record size, optional
*/
export function decryptStream(input, key, rs = ECE_RECORD_SIZE) {
const mode = 'decrypt';
const inputStream = transformStream(input, new StreamSlicer(rs, mode));
return transformStream(inputStream, new ECETransformer(mode, key, rs));
}

View File

@ -1,41 +1,6 @@
import hash from 'string-hash'; import hash from 'string-hash';
const experiments = { const experiments = {};
S9wqVl2SQ4ab2yZtqDI3Dw: {
id: 'S9wqVl2SQ4ab2yZtqDI3Dw',
run: function(variant, state, emitter) {
switch (variant) {
case 1:
state.promo = 'blue';
break;
case 2:
state.promo = 'pink';
break;
default:
state.promo = 'grey';
}
emitter.emit('render');
},
eligible: function() {
return (
!/firefox|fxios/i.test(navigator.userAgent) &&
document.querySelector('html').lang === 'en-US'
);
},
variant: function(state) {
const n = this.luckyNumber(state);
if (n < 0.33) {
return 0;
}
return n < 0.66 ? 1 : 2;
},
luckyNumber: function(state) {
return luckyNumber(
`${this.id}:${state.storage.get('testpilot_ga__cid')}`
);
}
}
};
//Returns a number between 0 and 1 //Returns a number between 0 and 1
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars

View File

@ -1,231 +0,0 @@
import FileSender from './fileSender';
import FileReceiver from './fileReceiver';
import {
copyToClipboard,
delay,
fadeOut,
openLinksInNewTab,
percent
} from './utils';
import * as metrics from './metrics';
export default function(state, emitter) {
let lastRender = 0;
let updateTitle = false;
function render() {
emitter.emit('render');
}
async function checkFiles() {
const files = state.storage.files.slice();
let rerender = false;
for (const file of files) {
const oldLimit = file.dlimit;
const oldTotal = file.dtotal;
await file.updateDownloadCount();
if (file.dtotal === file.dlimit) {
state.storage.remove(file.id);
rerender = true;
} else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) {
rerender = true;
}
}
if (rerender) {
render();
}
}
function updateProgress() {
if (updateTitle) {
emitter.emit('DOMTitleChange', percent(state.transfer.progressRatio));
}
render();
}
emitter.on('DOMContentLoaded', () => {
document.addEventListener('blur', () => (updateTitle = true));
document.addEventListener('focus', () => {
updateTitle = false;
emitter.emit('DOMTitleChange', 'Firefox Send');
});
checkFiles();
});
emitter.on('navigate', checkFiles);
emitter.on('render', () => {
lastRender = Date.now();
});
emitter.on('changeLimit', async ({ file, value }) => {
await file.changeLimit(value);
state.storage.writeFile(file);
metrics.changedDownloadLimit(file);
});
emitter.on('delete', async ({ file, location }) => {
try {
metrics.deletedUpload({
size: file.size,
time: file.time,
speed: file.speed,
type: file.type,
ttl: file.expiresAt - Date.now(),
location
});
state.storage.remove(file.id);
await file.del();
} catch (e) {
state.raven.captureException(e);
}
});
emitter.on('cancel', () => {
state.transfer.cancel();
});
emitter.on('upload', async ({ file, type }) => {
const size = file.size;
const sender = new FileSender(file);
sender.on('progress', updateProgress);
sender.on('encrypting', render);
state.transfer = sender;
state.uploading = true;
render();
const links = openLinksInNewTab();
await delay(200);
try {
metrics.startedUpload({ size, type });
const ownedFile = await sender.upload();
ownedFile.type = type;
state.storage.totalUploads += 1;
metrics.completedUpload(ownedFile);
state.storage.addFile(ownedFile);
const cancelBtn = document.getElementById('cancel-upload');
if (cancelBtn) {
cancelBtn.hidden = 'hidden';
}
await delay(1000);
await fadeOut('.page');
emitter.emit('pushState', `/share/${ownedFile.id}`);
} catch (err) {
if (err.message === '0') {
//cancelled. do nothing
metrics.cancelledUpload({ size, type });
render();
} else {
// eslint-disable-next-line no-console
console.error(err);
state.raven.captureException(err);
metrics.stoppedUpload({ size, type, err });
emitter.emit('pushState', '/error');
}
} finally {
openLinksInNewTab(links, false);
state.uploading = false;
state.transfer = null;
}
});
emitter.on('password', async ({ password, file }) => {
try {
state.settingPassword = true;
render();
await file.setPassword(password);
state.storage.writeFile(file);
metrics.addedPassword({ size: file.size });
await delay(1000);
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
state.passwordSetError = err;
} finally {
state.settingPassword = false;
}
render();
});
emitter.on('getMetadata', async () => {
const file = state.fileInfo;
const receiver = new FileReceiver(file);
try {
await receiver.getMetadata();
state.transfer = receiver;
} catch (e) {
if (e.message === '401') {
file.password = null;
if (!file.requiresPassword) {
return emitter.emit('pushState', '/404');
}
}
}
render();
});
emitter.on('download', async file => {
state.transfer.on('progress', updateProgress);
state.transfer.on('decrypting', render);
const links = openLinksInNewTab();
const size = file.size;
try {
const start = Date.now();
metrics.startedDownload({ size: file.size, ttl: file.ttl });
const dl = state.transfer.download();
render();
await dl;
const time = Date.now() - start;
const speed = size / (time / 1000);
await delay(1000);
await fadeOut('.page');
state.storage.totalDownloads += 1;
state.transfer.reset();
metrics.completedDownload({ size, time, speed });
emitter.emit('pushState', '/completed');
} catch (err) {
if (err.message === '0') {
// download cancelled
state.transfer.reset();
render();
} else {
// eslint-disable-next-line no-console
console.error(err);
state.transfer = null;
const location = err.message === '404' ? '/404' : '/error';
if (location === '/error') {
state.raven.captureException(err);
metrics.stoppedDownload({ size, err });
}
emitter.emit('pushState', location);
}
} finally {
openLinksInNewTab(links, false);
}
});
emitter.on('copy', ({ url, location }) => {
copyToClipboard(url);
metrics.copiedLink({ location });
});
setInterval(() => {
// poll for updates of the download counts
// TODO something for the share page: || state.route === '/share/:id'
if (state.route === '/') {
checkFiles();
}
}, 2 * 60 * 1000);
setInterval(() => {
// poll for rerendering the file list countdown timers
if (
state.route === '/' &&
state.storage.files.length > 0 &&
Date.now() - lastRender > 30000
) {
render();
}
}, 60000);
}

View File

@ -1,7 +1,9 @@
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import Keychain from './keychain'; import Keychain from './keychain';
import { bytes } from './utils'; import { delay, bytes, streamToArrayBuffer } from './utils';
import { metadata, downloadFile } from './api'; import { downloadFile, metadata, getApiUrl } from './api';
import { blobStream } from './streams';
import Zip from './zip';
export default class FileReceiver extends Nanobus { export default class FileReceiver extends Nanobus {
constructor(fileInfo) { constructor(fileInfo) {
@ -43,15 +45,33 @@ export default class FileReceiver extends Nanobus {
async getMetadata() { async getMetadata() {
const meta = await metadata(this.fileInfo.id, this.keychain); const meta = await metadata(this.fileInfo.id, this.keychain);
this.keychain.setIV(meta.iv);
this.fileInfo.name = meta.name; this.fileInfo.name = meta.name;
this.fileInfo.type = meta.type; this.fileInfo.type = meta.type;
this.fileInfo.iv = meta.iv; this.fileInfo.iv = meta.iv;
this.fileInfo.size = +meta.size; this.fileInfo.size = +meta.size;
this.fileInfo.manifest = meta.manifest;
this.state = 'ready'; this.state = 'ready';
} }
async download(noSave = false) { sendMessageToSw(msg) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = function(event) {
if (event.data === undefined) {
reject('bad response from serviceWorker');
} else if (event.data.error !== undefined) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
navigator.serviceWorker.controller.postMessage(msg, [channel.port2]);
});
}
async downloadBlob(noSave = false) {
this.state = 'downloading'; this.state = 'downloading';
this.downloadRequest = await downloadFile( this.downloadRequest = await downloadFile(
this.fileInfo.id, this.fileInfo.id,
@ -67,7 +87,14 @@ export default class FileReceiver extends Nanobus {
this.msg = 'decryptingFile'; this.msg = 'decryptingFile';
this.state = 'decrypting'; this.state = 'decrypting';
this.emit('decrypting'); this.emit('decrypting');
const plaintext = await this.keychain.decryptFile(ciphertext); let size = this.fileInfo.size;
let plainStream = this.keychain.decryptStream(blobStream(ciphertext));
if (this.fileInfo.type === 'send-archive') {
const zip = new Zip(this.fileInfo.manifest, plainStream);
plainStream = zip.stream;
size = zip.size;
}
const plaintext = await streamToArrayBuffer(plainStream, size);
if (!noSave) { if (!noSave) {
await saveFile({ await saveFile({
plaintext, plaintext,
@ -76,12 +103,96 @@ export default class FileReceiver extends Nanobus {
}); });
} }
this.msg = 'downloadFinish'; this.msg = 'downloadFinish';
this.emit('complete');
this.state = 'complete'; this.state = 'complete';
} catch (e) { } catch (e) {
this.downloadRequest = null; this.downloadRequest = null;
throw e; throw e;
} }
} }
async downloadStream(noSave = false) {
const onprogress = p => {
this.progress = [p, this.fileInfo.size];
this.emit('progress');
};
this.downloadRequest = {
cancel: () => {
this.sendMessageToSw({ request: 'cancel', id: this.fileInfo.id });
}
};
try {
this.state = 'downloading';
const info = {
request: 'init',
id: this.fileInfo.id,
filename: this.fileInfo.name,
type: this.fileInfo.type,
manifest: this.fileInfo.manifest,
key: this.fileInfo.secretKey,
requiresPassword: this.fileInfo.requiresPassword,
password: this.fileInfo.password,
url: this.fileInfo.url,
size: this.fileInfo.size,
nonce: this.keychain.nonce,
noSave
};
await this.sendMessageToSw(info);
onprogress(0);
if (noSave) {
const res = await fetch(getApiUrl(`/api/download/${this.fileInfo.id}`));
if (res.status !== 200) {
throw new Error(res.status);
}
} else {
const downloadPath = `/api/download/${this.fileInfo.id}`;
let downloadUrl = getApiUrl(downloadPath);
if (downloadUrl === downloadPath) {
downloadUrl = `${location.protocol}//${location.host}/api/download/${
this.fileInfo.id
}`;
}
const a = document.createElement('a');
a.href = downloadUrl;
document.body.appendChild(a);
a.click();
}
let prog = 0;
while (prog < this.fileInfo.size) {
const msg = await this.sendMessageToSw({
request: 'progress',
id: this.fileInfo.id
});
prog = msg.progress;
onprogress(prog);
await delay(1000);
}
this.downloadRequest = null;
this.msg = 'downloadFinish';
this.emit('complete');
this.state = 'complete';
} catch (e) {
this.downloadRequest = null;
if (e === 'cancelled' || e.message === '400') {
throw new Error(0);
}
throw e;
}
}
download(options) {
if (options.stream) {
return this.downloadStream(options.noSave);
}
return this.downloadBlob(options.noSave);
}
} }
async function saveFile(file) { async function saveFile(file) {

View File

@ -1,14 +1,13 @@
/* global EXPIRE_SECONDS */
import Nanobus from 'nanobus'; import Nanobus from 'nanobus';
import OwnedFile from './ownedFile'; import OwnedFile from './ownedFile';
import Keychain from './keychain'; import Keychain from './keychain';
import { arrayToB64, bytes } from './utils'; import { arrayToB64, bytes } from './utils';
import { uploadFile } from './api'; import { uploadWs } from './api';
import { encryptedSize } from './utils';
export default class FileSender extends Nanobus { export default class FileSender extends Nanobus {
constructor(file) { constructor() {
super('FileSender'); super('FileSender');
this.file = file;
this.keychain = new Keychain(); this.keychain = new Keychain();
this.reset(); this.reset();
} }
@ -18,7 +17,9 @@ export default class FileSender extends Nanobus {
} }
get progressIndefinite() { get progressIndefinite() {
return ['fileSizeProgress', 'notifyUploadDone'].indexOf(this.msg) === -1; return (
['fileSizeProgress', 'notifyUploadEncryptDone'].indexOf(this.msg) === -1
);
} }
get sizes() { get sizes() {
@ -42,67 +43,61 @@ export default class FileSender extends Nanobus {
} }
} }
readFile() { async upload(archive, bearerToken) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsArrayBuffer(this.file);
// TODO: progress?
reader.onload = function(event) {
const plaintext = new Uint8Array(this.result);
resolve(plaintext);
};
reader.onerror = function(err) {
reject(err);
};
});
}
async upload() {
const start = Date.now(); const start = Date.now();
const plaintext = await this.readFile();
if (this.cancelled) { if (this.cancelled) {
throw new Error(0); throw new Error(0);
} }
this.msg = 'encryptingFile'; this.msg = 'encryptingFile';
this.emit('encrypting'); this.emit('encrypting');
const encrypted = await this.keychain.encryptFile(plaintext); const totalSize = encryptedSize(archive.size);
const metadata = await this.keychain.encryptMetadata(this.file); const encStream = await this.keychain.encryptStream(archive.stream);
const metadata = await this.keychain.encryptMetadata(archive);
const authKeyB64 = await this.keychain.authKeyB64(); const authKeyB64 = await this.keychain.authKeyB64();
if (this.cancelled) {
throw new Error(0); this.uploadRequest = uploadWs(
} encStream,
this.uploadRequest = uploadFile(
encrypted,
metadata, metadata,
authKeyB64, authKeyB64,
this.keychain, archive.timeLimit,
archive.dlimit,
bearerToken,
p => { p => {
this.progress = p; this.progress = [p, totalSize];
this.emit('progress'); this.emit('progress');
} }
); );
if (this.cancelled) {
throw new Error(0);
}
this.msg = 'fileSizeProgress'; this.msg = 'fileSizeProgress';
this.emit('progress'); // HACK to kick MS Edge this.emit('progress'); // HACK to kick MS Edge
try { try {
const result = await this.uploadRequest.result; const result = await this.uploadRequest.result;
const time = Date.now() - start; const time = Date.now() - start;
this.msg = 'notifyUploadDone'; this.msg = 'notifyUploadEncryptDone';
this.uploadRequest = null; this.uploadRequest = null;
this.progress = [1, 1]; this.progress = [1, 1];
const secretKey = arrayToB64(this.keychain.rawSecret); const secretKey = arrayToB64(this.keychain.rawSecret);
const ownedFile = new OwnedFile({ const ownedFile = new OwnedFile({
id: result.id, id: result.id,
url: `${result.url}#${secretKey}`, url: `${result.url}#${secretKey}`,
name: this.file.name, name: archive.name,
size: this.file.size, size: archive.size,
manifest: archive.manifest,
time: time, time: time,
speed: this.file.size / (time / 1000), speed: archive.size / (time / 1000),
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + EXPIRE_SECONDS * 1000, expiresAt: Date.now() + archive.timeLimit * 1000,
secretKey: secretKey, secretKey: secretKey,
nonce: this.keychain.nonce, nonce: this.keychain.nonce,
ownerToken: result.ownerToken ownerToken: result.ownerToken,
dlimit: archive.dlimit,
timeLimit: archive.timeLimit
}); });
return ownedFile; return ownedFile;
} catch (e) { } catch (e) {
this.msg = 'errorPageHeader'; this.msg = 'errorPageHeader';

181
app/fxa.js Normal file
View File

@ -0,0 +1,181 @@
/* global AUTH_CONFIG */
import { arrayToB64, b64ToArray } from './utils';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function getOtherInfo(enc) {
const name = encoder.encode(enc);
const length = 256;
const buffer = new ArrayBuffer(name.length + 16);
const dv = new DataView(buffer);
const result = new Uint8Array(buffer);
let i = 0;
dv.setUint32(i, name.length);
i += 4;
result.set(name, i);
i += name.length;
dv.setUint32(i, 0);
i += 4;
dv.setUint32(i, 0);
i += 4;
dv.setUint32(i, length);
return result;
}
function concat(b1, b2) {
const result = new Uint8Array(b1.length + b2.length);
result.set(b1, 0);
result.set(b2, b1.length);
return result;
}
async function concatKdf(key, enc) {
if (key.length !== 32) {
throw new Error('unsupported key length');
}
const otherInfo = getOtherInfo(enc);
const buffer = new ArrayBuffer(4 + key.length + otherInfo.length);
const dv = new DataView(buffer);
const concat = new Uint8Array(buffer);
dv.setUint32(0, 1);
concat.set(key, 4);
concat.set(otherInfo, key.length + 4);
const result = await crypto.subtle.digest('SHA-256', concat);
return new Uint8Array(result);
}
export async function prepareScopedBundleKey(storage) {
const keys = await crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256'
},
true,
['deriveBits']
);
const privateJwk = await crypto.subtle.exportKey('jwk', keys.privateKey);
const publicJwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
const kid = await crypto.subtle.digest(
'SHA-256',
encoder.encode(JSON.stringify(publicJwk))
);
privateJwk.kid = kid;
publicJwk.kid = kid;
storage.set('scopedBundlePrivateKey', JSON.stringify(privateJwk));
return arrayToB64(encoder.encode(JSON.stringify(publicJwk)));
}
export async function decryptBundle(storage, bundle) {
const privateJwk = JSON.parse(storage.get('scopedBundlePrivateKey'));
storage.remove('scopedBundlePrivateKey');
const privateKey = await crypto.subtle.importKey(
'jwk',
privateJwk,
{
name: 'ECDH',
namedCurve: 'P-256'
},
false,
['deriveBits']
);
const jweParts = bundle.split('.');
if (jweParts.length !== 5) {
throw new Error('invalid jwe');
}
const header = JSON.parse(decoder.decode(b64ToArray(jweParts[0])));
const additionalData = encoder.encode(jweParts[0]);
const iv = b64ToArray(jweParts[2]);
const ciphertext = b64ToArray(jweParts[3]);
const tag = b64ToArray(jweParts[4]);
if (header.alg !== 'ECDH-ES' || header.enc !== 'A256GCM') {
throw new Error('unsupported jwe type');
}
const publicKey = await crypto.subtle.importKey(
'jwk',
header.epk,
{
name: 'ECDH',
namedCurve: 'P-256'
},
false,
[]
);
const sharedBits = await crypto.subtle.deriveBits(
{
name: 'ECDH',
public: publicKey
},
privateKey,
256
);
const rawSharedKey = await concatKdf(new Uint8Array(sharedBits), header.enc);
const sharedKey = await crypto.subtle.importKey(
'raw',
rawSharedKey,
{
name: 'AES-GCM'
},
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: additionalData,
tagLength: tag.length * 8
},
sharedKey,
concat(ciphertext, tag)
);
return JSON.parse(decoder.decode(plaintext));
}
export async function preparePkce(storage) {
const verifier = arrayToB64(crypto.getRandomValues(new Uint8Array(64)));
storage.set('pkceVerifier', verifier);
const challenge = await crypto.subtle.digest(
'SHA-256',
encoder.encode(verifier)
);
return arrayToB64(new Uint8Array(challenge));
}
export async function deriveFileListKey(ikm) {
const baseKey = await crypto.subtle.importKey(
'raw',
b64ToArray(ikm),
{ name: 'HKDF' },
false,
['deriveKey']
);
const fileListKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
salt: new Uint8Array(),
info: encoder.encode('fileList'),
hash: 'SHA-256'
},
baseKey,
{
name: 'AES-GCM',
length: 128
},
true,
['encrypt', 'decrypt']
);
const rawFileListKey = await crypto.subtle.exportKey('raw', fileListKey);
return arrayToB64(new Uint8Array(rawFileListKey));
}
export async function getFileListKey(storage, bundle) {
const jwks = await decryptBundle(storage, bundle);
const jwk = jwks[AUTH_CONFIG.key_scope];
return deriveFileListKey(jwk.k);
}

View File

@ -1,22 +1,17 @@
import { arrayToB64, b64ToArray } from './utils'; import { arrayToB64, b64ToArray } from './utils';
import { decryptStream, encryptStream } from './ece.js';
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
export default class Keychain { export default class Keychain {
constructor(secretKeyB64, nonce, ivB64) { constructor(secretKeyB64, nonce) {
this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ=='; this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ==';
if (ivB64) {
this.iv = b64ToArray(ivB64);
} else {
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
}
if (secretKeyB64) { if (secretKeyB64) {
this.rawSecret = b64ToArray(secretKeyB64); this.rawSecret = b64ToArray(secretKeyB64);
} else { } else {
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16)); this.rawSecret = crypto.getRandomValues(new Uint8Array(16));
} }
this.secretKeyPromise = window.crypto.subtle.importKey( this.secretKeyPromise = crypto.subtle.importKey(
'raw', 'raw',
this.rawSecret, this.rawSecret,
'HKDF', 'HKDF',
@ -24,7 +19,7 @@ export default class Keychain {
['deriveKey'] ['deriveKey']
); );
this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) { this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) {
return window.crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
{ {
name: 'HKDF', name: 'HKDF',
salt: new Uint8Array(), salt: new Uint8Array(),
@ -41,7 +36,7 @@ export default class Keychain {
); );
}); });
this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) { this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) {
return window.crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
{ {
name: 'HKDF', name: 'HKDF',
salt: new Uint8Array(), salt: new Uint8Array(),
@ -58,7 +53,7 @@ export default class Keychain {
); );
}); });
this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) { this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) {
return window.crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
{ {
name: 'HKDF', name: 'HKDF',
salt: new Uint8Array(), salt: new Uint8Array(),
@ -86,17 +81,13 @@ export default class Keychain {
} }
} }
setIV(ivB64) {
this.iv = b64ToArray(ivB64);
}
setPassword(password, shareUrl) { setPassword(password, shareUrl) {
this.authKeyPromise = window.crypto.subtle this.authKeyPromise = crypto.subtle
.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [ .importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [
'deriveKey' 'deriveKey'
]) ])
.then(passwordKey => .then(passwordKey =>
window.crypto.subtle.deriveKey( crypto.subtle.deriveKey(
{ {
name: 'PBKDF2', name: 'PBKDF2',
salt: encoder.encode(shareUrl), salt: encoder.encode(shareUrl),
@ -115,7 +106,7 @@ export default class Keychain {
} }
setAuthKey(authKeyB64) { setAuthKey(authKeyB64) {
this.authKeyPromise = window.crypto.subtle.importKey( this.authKeyPromise = crypto.subtle.importKey(
'raw', 'raw',
b64ToArray(authKeyB64), b64ToArray(authKeyB64),
{ {
@ -129,13 +120,13 @@ export default class Keychain {
async authKeyB64() { async authKeyB64() {
const authKey = await this.authKeyPromise; const authKey = await this.authKeyPromise;
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey); const rawAuth = await crypto.subtle.exportKey('raw', authKey);
return arrayToB64(new Uint8Array(rawAuth)); return arrayToB64(new Uint8Array(rawAuth));
} }
async authHeader() { async authHeader() {
const authKey = await this.authKeyPromise; const authKey = await this.authKeyPromise;
const sig = await window.crypto.subtle.sign( const sig = await crypto.subtle.sign(
{ {
name: 'HMAC' name: 'HMAC'
}, },
@ -145,23 +136,9 @@ export default class Keychain {
return `send-v1 ${arrayToB64(new Uint8Array(sig))}`; return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
} }
async encryptFile(plaintext) {
const encryptKey = await this.encryptKeyPromise;
const ciphertext = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: this.iv,
tagLength: 128
},
encryptKey,
plaintext
);
return ciphertext;
}
async encryptMetadata(metadata) { async encryptMetadata(metadata) {
const metaKey = await this.metaKeyPromise; const metaKey = await this.metaKeyPromise;
const ciphertext = await window.crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: new Uint8Array(12), iv: new Uint8Array(12),
@ -170,32 +147,27 @@ export default class Keychain {
metaKey, metaKey,
encoder.encode( encoder.encode(
JSON.stringify({ JSON.stringify({
iv: arrayToB64(this.iv),
name: metadata.name, name: metadata.name,
type: metadata.type || 'application/octet-stream' size: metadata.size,
type: metadata.type || 'application/octet-stream',
manifest: metadata.manifest || {}
}) })
) )
); );
return ciphertext; return ciphertext;
} }
async decryptFile(ciphertext) { encryptStream(plainStream) {
const encryptKey = await this.encryptKeyPromise; return encryptStream(plainStream, this.rawSecret);
const plaintext = await window.crypto.subtle.decrypt( }
{
name: 'AES-GCM', decryptStream(cryptotext) {
iv: this.iv, return decryptStream(cryptotext, this.rawSecret);
tagLength: 128
},
encryptKey,
ciphertext
);
return plaintext;
} }
async decryptMetadata(ciphertext) { async decryptMetadata(ciphertext) {
const metaKey = await this.metaKeyPromise; const metaKey = await this.metaKeyPromise;
const plaintext = await window.crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: new Uint8Array(12), iv: new Uint8Array(12),

26
app/locale.js Normal file
View File

@ -0,0 +1,26 @@
import { FluentBundle } from 'fluent';
function makeBundle(locale, ftl) {
const bundle = new FluentBundle(locale, { useIsolating: false });
bundle.addMessages(ftl);
return bundle;
}
export async function getTranslator(locale) {
const bundles = [];
const { default: en } = await import('../public/locales/en-US/send.ftl');
if (locale !== 'en-US') {
const {
default: ftl
} = await import(`../public/locales/${locale}/send.ftl`);
bundles.push(makeBundle(locale, ftl));
}
bundles.push(makeBundle('en-US', en));
return function(id, data) {
for (let bundle of bundles) {
if (bundle.hasMessage(id)) {
return bundle.format(bundle.getMessage(id), data);
}
}
};
}

View File

@ -1,16 +1,289 @@
@import './base.css'; @tailwind preflight;
@import './templates/header/header.css'; @tailwind components;
@import './templates/downloadButton/downloadButton.css';
@import './templates/progress/progress.css'; :not(input) {
@import './templates/passwordInput/passwordInput.css'; -webkit-user-select: none;
@import './templates/downloadPassword/downloadPassword.css'; -moz-user-select: none;
@import './templates/setPasswordSection/setPasswordSection.css'; -ms-user-select: none;
@import './templates/footer/footer.css'; user-select: none;
@import './templates/fxPromo/fxPromo.css'; }
@import './templates/selectbox/selectbox.css';
@import './templates/fileList/fileList.css'; a {
@import './templates/file/file.css'; color: inherit;
@import './templates/popup/popup.css'; text-decoration: none;
@import './pages/welcome/welcome.css'; }
@import './pages/share/share.css';
@import './pages/unsupported/unsupported.css'; a:focus {
outline: 1px dotted grey;
}
body {
background-image: url('../assets/bg.svg');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.btn {
@apply bg-blue-dark;
@apply text-white;
@apply cursor-pointer;
@apply py-4;
@apply px-6;
}
.btn:hover {
@apply bg-blue-darker;
}
.btn:focus {
@apply bg-blue-darker;
}
.checkbox {
@apply leading-normal;
@apply select-none;
}
.checkbox > input[type='checkbox'] {
@apply absolute;
@apply opacity-0;
}
.checkbox > label {
@apply cursor-pointer;
}
.checkbox > label::before {
/* @apply bg-grey-lightest; */
@apply border;
@apply rounded-sm;
content: '';
height: 1.5rem;
width: 1.5rem;
margin-right: 0.5rem;
float: left;
}
.checkbox > label:hover::before {
@apply border-blue-dark;
}
.checkbox > input:focus + label::before {
@apply border-blue-dark;
}
.checkbox > input:checked + label::before {
@apply bg-blue-dark;
@apply border-blue-dark;
background-image: url('../assets/lock.svg');
background-position: center;
background-size: 1.25rem;
background-repeat: no-repeat;
}
.checkbox > input:disabled + label {
cursor: auto;
}
.checkbox > input:disabled + label::before {
@apply bg-blue-dark;
@apply border-blue-dark;
background-image: url('../assets/lock.svg');
background-position: center;
background-size: 1.25rem;
background-repeat: no-repeat;
cursor: auto;
}
details {
overflow: hidden;
}
details > summary::-webkit-details-marker {
display: none;
}
details > summary > svg {
transition: all 0.25s cubic-bezier(0.07, 0.95, 0, 1);
}
details[open] {
overflow-y: auto;
}
details[open] > summary > svg {
transform: rotate(90deg);
}
footer li:hover {
text-decoration: underline;
}
.feedback-link {
background-color: #000;
background-image: url('../assets/feedback.svg');
background-position: 0.125rem 0.25rem;
background-repeat: no-repeat;
background-size: 1.125rem;
color: #fff;
display: block;
font-size: 0.75rem;
line-height: 0.75rem;
padding: 0.375rem 0.375rem 0.375rem 1.25rem;
text-indent: 0.125rem;
white-space: nowrap;
}
.intro {
max-width: 100%;
height: unset;
}
.main {
display: flex;
position: relative;
max-width: 64rem;
width: 100%;
height: 100%;
}
.main > section {
@apply bg-white;
}
.mozilla-logo {
background-image: url('../assets/mozilla-logo.svg');
background-repeat: no-repeat;
background-size: 100px, 32px;
overflow: hidden;
text-indent: 120%;
white-space: nowrap;
display: inline-block;
height: 32px;
width: 100px;
flex-shrink: 0;
}
#password-msg::after {
content: '\200b';
}
progress {
@apply bg-grey-light;
@apply rounded-sm;
@apply w-full;
@apply h-1;
}
progress::-webkit-progress-bar {
@apply bg-grey-light;
@apply rounded-sm;
@apply w-full;
@apply h-1;
}
progress::-webkit-progress-value {
/* stylelint-disable */
background-image: -webkit-linear-gradient(
-45deg,
transparent 20%,
rgba(255, 255, 255, 0.4) 20%,
rgba(255, 255, 255, 0.4) 40%,
transparent 40%,
transparent 60%,
rgba(255, 255, 255, 0.4) 60%,
rgba(255, 255, 255, 0.4) 80%,
transparent 80%
),
-webkit-linear-gradient(left, #0a84ff, #0a84ff);
/* stylelint-enable */
border-radius: 2px;
background-size: 21px 20px, 100% 100%, 100% 100%;
-webkit-animation: animate-stripes 1s linear infinite;
}
progress::-moz-progress-bar {
/* stylelint-disable */
background-image: -moz-linear-gradient(
135deg,
transparent 20%,
rgba(255, 255, 255, 0.4) 20%,
rgba(255, 255, 255, 0.4) 40%,
transparent 40%,
transparent 60%,
rgba(255, 255, 255, 0.4) 60%,
rgba(255, 255, 255, 0.4) 80%,
transparent 80%
),
-moz-linear-gradient(left, #0a84ff, #0a84ff);
/* stylelint-enable */
border-radius: 2px;
background-size: 21px 20px, 100% 100%, 100% 100%;
animation: animate-stripes 1s linear infinite;
}
@-webkit-keyframes animate-stripes {
100% {
background-position: -21px 0;
}
}
@keyframes animate-stripes {
100% {
background-position: -21px 0;
}
}
select {
background-image: url('../assets/select-arrow.svg');
background-position: calc(100% - 0.75rem);
background-repeat: no-repeat;
}
@screen md {
.intro {
max-width: unset;
height: unset;
margin-bottom: -3rem;
margin-right: -7rem;
}
.main {
@apply flex-1;
@apply self-center;
@apply items-center;
@apply m-auto;
@apply py-8;
min-height: 36rem;
max-height: 40rem;
width: calc(100% - 3rem);
}
}
@tailwind utilities;
@responsive {
.shadow-light {
box-shadow: 0 0 8px 0 rgba(12, 12, 13, 0.1);
}
.shadow-big {
box-shadow: 0 0 32px 0 rgba(12, 12, 13, 0.1),
0 2px 16px 0 rgba(12, 12, 13, 0.05);
}
}
@variants focus {
.outline {
outline: 1px dotted grey;
}
}
.word-break-all {
word-break: break-all;
}

View File

@ -1,54 +1,64 @@
/* global DEFAULTS LIMITS LOCALE */
import 'core-js';
import 'fast-text-encoding'; // MS Edge support import 'fast-text-encoding'; // MS Edge support
import 'fluent-intl-polyfill'; import 'fluent-intl-polyfill';
import app from './routes'; import choo from 'choo';
import locale from '../common/locales'; import nanotiming from 'nanotiming';
import fileManager from './fileManager'; import routes from './routes';
import getCapabilities from './capabilities';
import controller from './controller';
import dragManager from './dragManager'; import dragManager from './dragManager';
import pasteManager from './pasteManager'; import pasteManager from './pasteManager';
import { canHasSend } from './utils';
import storage from './storage'; import storage from './storage';
import metrics from './metrics'; import metrics from './metrics';
import experiments from './experiments'; import experiments from './experiments';
import Raven from 'raven-js'; import Raven from 'raven-js';
import './main.css';
import User from './user';
import { getTranslator } from './locale';
import Archive from './archive';
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install(); Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install();
} }
app.use((state, emitter) => { if (process.env.NODE_ENV === 'production') {
state.transfer = null; nanotiming.disabled = true;
state.fileInfo = null; }
state.translate = locale.getTranslator();
state.storage = storage;
state.raven = Raven;
window.appState = state;
emitter.on('DOMContentLoaded', async function checkSupport() {
let unsupportedReason = null;
if (
// Firefox < 50
/firefox/i.test(navigator.userAgent) &&
parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) < 50
) {
unsupportedReason = 'outdated';
}
const ok = await canHasSend();
if (!ok) {
unsupportedReason = /firefox/i.test(navigator.userAgent)
? 'outdated'
: 'gcm';
}
if (unsupportedReason) {
setTimeout(() =>
emitter.emit('replaceState', `/unsupported/${unsupportedReason}`)
);
}
});
});
app.use(metrics); (async function start() {
app.use(fileManager); const capabilities = await getCapabilities();
app.use(dragManager); if (
app.use(experiments); !capabilities.crypto &&
app.use(pasteManager); window.location.pathname !== '/unsupported/crypto'
) {
return window.location.assign('/unsupported/crypto');
}
if (capabilities.serviceWorker) {
await navigator.serviceWorker.register('/serviceWorker.js');
await navigator.serviceWorker.ready;
}
app.mount('body'); const translate = await getTranslator(LOCALE);
window.initialState = {
LIMITS,
DEFAULTS,
archive: new Archive([], DEFAULTS.EXPIRE_SECONDS),
capabilities,
translate,
storage,
raven: Raven,
user: new User(storage, LIMITS, window.AUTH_CONFIG),
transfer: null,
fileInfo: null
};
const app = routes(choo());
window.app = app;
app.use(metrics);
app.use(controller);
app.use(dragManager);
app.use(experiments);
app.use(pasteManager);
app.mount('body');
})();

View File

@ -1,297 +1,178 @@
import testPilotGA from 'testpilot-ga/src/TestPilotGA';
import storage from './storage'; import storage from './storage';
import { platform } from './utils';
let hasLocalStorage = false; import { sendMetrics } from './api';
try {
hasLocalStorage = typeof localStorage !== 'undefined';
} catch (e) {
// when disabled, any mention of localStorage throws an error
}
const analytics = new testPilotGA({
an: 'Firefox Send',
ds: 'web',
tid: window.GOOGLE_ANALYTICS_ID
});
let appState = null; let appState = null;
let experiment = null; // let experiment = null;
const HOUR = 1000 * 60 * 60;
const events = [];
let session_id = Date.now();
const lang = document.querySelector('html').lang;
export default function initialize(state, emitter) { export default function initialize(state, emitter) {
appState = state; appState = state;
if (!appState.user.firstAction) {
appState.user.firstAction = appState.route === '/' ? 'upload' : 'download';
}
emitter.on('DOMContentLoaded', () => { emitter.on('DOMContentLoaded', () => {
addExitHandlers(); // experiment = storage.enrolled[0];
experiment = storage.enrolled[0]; const query = appState.query;
sendEvent(category(), 'visit', { addEvent('client_visit', {
cm5: storage.totalUploads, entrypoint: appState.route === '/' ? 'upload' : 'download',
cm6: storage.files.length, referrer: document.referrer,
cm7: storage.totalDownloads utm_campaign: query.utm_campaign,
utm_content: query.utm_content,
utm_medium: query.utm_medium,
utm_source: query.utm_source,
utm_term: query.utm_term
}); });
//TODO restart handlers... somewhere
}); });
emitter.on('exit', exitEvent);
emitter.on('experiment', experimentEvent); emitter.on('experiment', experimentEvent);
window.addEventListener('unload', submitEvents);
} }
function category() { function sizeOrder(n) {
switch (appState.route) { return Math.floor(Math.log10(n));
case '/':
case '/share/:id':
return 'sender';
case '/download/:id/:key':
case '/download/:id':
case '/completed':
return 'recipient';
default:
return 'other';
}
} }
function sendEvent() { function submitEvents() {
const args = Array.from(arguments); if (navigator.doNotTrack === '1') {
if (experiment && args[2]) { return;
args[2].xid = experiment[0];
args[2].xvar = experiment[1];
} }
return ( sendMetrics(
hasLocalStorage && analytics.sendEvent.apply(analytics, args).catch(() => 0) new Blob(
[
JSON.stringify({
now: Date.now(),
session_id,
lang,
platform: platform(),
events
})
],
{ type: 'text/plain' } // see http://crbug.com/490015
)
); );
events.splice(0);
} }
function urlToMetric(url) { async function addEvent(event_type, event_properties) {
switch (url) { const user_id = await appState.user.metricId();
case 'https://www.mozilla.org/': const device_id = await appState.user.deviceId();
return 'mozilla'; events.push({
case 'https://www.mozilla.org/about/legal': device_id,
return 'legal'; event_properties,
case 'https://testpilot.firefox.com/about': event_type,
return 'about'; time: Date.now(),
case 'https://testpilot.firefox.com/privacy': user_id,
return 'privacy'; user_properties: {
case 'https://testpilot.firefox.com/terms': anonymous: !appState.user.loggedIn,
return 'terms'; first_action: appState.user.firstAction,
case 'https://www.mozilla.org/privacy/websites/#cookies': active_count: storage.files.length
return 'cookies';
case 'https://github.com/mozilla/send':
return 'github';
case 'https://twitter.com/FxTestPilot':
return 'twitter';
case 'https://www.mozilla.org/firefox/new/?scene=2':
return 'download-firefox';
case 'https://qsurvey.mozilla.com/s3/txp-firefox-send':
return 'survey';
case 'https://testpilot.firefox.com/':
case 'https://testpilot.firefox.com/experiments/send':
return 'testpilot';
case 'https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com':
return 'promo';
default:
return 'other';
}
}
function setReferrer(state) {
if (category() === 'sender') {
if (state) {
storage.referrer = `${state}-upload`;
}
} else if (category() === 'recipient') {
if (state) {
storage.referrer = `${state}-download`;
} }
});
if (events.length === 25) {
submitEvents();
} }
} }
function externalReferrer() { function cancelledUpload(archive, duration) {
if (/^https:\/\/testpilot\.firefox\.com/.test(document.referrer)) { return addEvent('client_upload', {
return 'testpilot'; download_limit: archive.dlimit,
} duration: sizeOrder(duration),
return 'external'; file_count: archive.numFiles,
} password_protected: !!archive.password,
size: sizeOrder(archive.size),
function takeReferrer() { status: 'cancel',
const referrer = storage.referrer || externalReferrer(); time_limit: archive.timeLimit
storage.referrer = null;
return referrer;
}
function startedUpload(params) {
return sendEvent('sender', 'upload-started', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length + 1,
cm7: storage.totalDownloads,
cd1: params.type,
cd5: takeReferrer()
}); });
} }
function cancelledUpload(params) { function completedUpload(archive, duration) {
setReferrer('cancelled'); return addEvent('client_upload', {
return sendEvent('sender', 'upload-stopped', { download_limit: archive.dlimit,
cm1: params.size, duration: sizeOrder(duration),
cm5: storage.totalUploads, file_count: archive.numFiles,
cm6: storage.files.length, password_protected: !!archive.password,
cm7: storage.totalDownloads, size: sizeOrder(archive.size),
cd1: params.type, status: 'ok',
cd2: 'cancelled' time_limit: archive.timeLimit
}); });
} }
function completedUpload(params) { function stoppedUpload(archive) {
return sendEvent('sender', 'upload-stopped', { return addEvent('client_upload', {
cm1: params.size, download_limit: archive.dlimit,
cm2: params.time, file_count: archive.numFiles,
cm3: params.speed, password_protected: !!archive.password,
cm5: storage.totalUploads, size: sizeOrder(archive.size),
cm6: storage.files.length, status: 'error',
cm7: storage.totalDownloads, time_limit: archive.timeLimit
cd1: params.type,
cd2: 'completed'
});
}
function addedPassword(params) {
return sendEvent('sender', 'password-added', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads
});
}
function startedDownload(params) {
return sendEvent('recipient', 'download-started', {
cm1: params.size,
cm4: params.ttl,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads
}); });
} }
function stoppedDownload(params) { function stoppedDownload(params) {
return sendEvent('recipient', 'download-stopped', { return addEvent('client_download', {
cm1: params.size, duration: sizeOrder(params.duration),
cm5: storage.totalUploads, password_protected: params.password_protected,
cm6: storage.files.length, size: sizeOrder(params.size),
cm7: storage.totalDownloads, status: 'error'
cd2: 'errored',
cd6: params.err
});
}
function cancelledDownload(params) {
setReferrer('cancelled');
return sendEvent('recipient', 'download-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'cancelled'
});
}
function stoppedUpload(params) {
return sendEvent('sender', 'upload-stopped', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd2: 'errored',
cd6: params.err
});
}
function changedDownloadLimit(params) {
return sendEvent('sender', 'download-limit-changed', {
cm1: params.size,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cm8: params.dlimit
}); });
} }
function completedDownload(params) { function completedDownload(params) {
return sendEvent('recipient', 'download-stopped', { return addEvent('client_download', {
cm1: params.size, duration: sizeOrder(params.duration),
cm2: params.time, password_protected: params.password_protected,
cm3: params.speed, size: sizeOrder(params.size),
cm5: storage.totalUploads, status: 'ok'
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd2: 'completed'
}); });
} }
function deletedUpload(params) { function deletedUpload(ownedFile) {
return sendEvent(category(), 'upload-deleted', { return addEvent('client_delete', {
cm1: params.size, age: Math.floor((Date.now() - ownedFile.createdAt) / HOUR),
cm2: params.time, downloaded: ownedFile.dtotal > 0,
cm3: params.speed, status: 'ok'
cm4: params.ttl,
cm5: storage.totalUploads,
cm6: storage.files.length,
cm7: storage.totalDownloads,
cd1: params.type,
cd4: params.location
});
}
function unsupported(params) {
return sendEvent(category(), 'unsupported', {
cd6: params.err
});
}
function copiedLink(params) {
return sendEvent('sender', 'copied', {
cd4: params.location
});
}
function exitEvent(target) {
return sendEvent(category(), 'exited', {
cd3: urlToMetric(target.currentTarget.href)
}); });
} }
function experimentEvent(params) { function experimentEvent(params) {
return sendEvent(category(), 'experiment', params); return addEvent('client_experiment', params);
} }
// eslint-disable-next-line no-unused-vars function submittedSignup(params) {
function addExitHandlers() { return addEvent('client_login', {
const links = Array.from(document.querySelectorAll('a')); status: 'ok',
links.forEach(l => { trigger: params.trigger
if (/^http/.test(l.getAttribute('href'))) {
l.addEventListener('click', exitEvent);
}
}); });
} }
function restart(state) { function canceledSignup(params) {
setReferrer(state); return addEvent('client_login', {
return sendEvent(category(), 'restarted', { status: 'cancel',
cd2: state trigger: params.trigger
}); });
} }
function loggedOut(params) {
addEvent('client_logout', {
status: 'ok',
trigger: params.trigger
});
// flush events and start new anon session
submitEvents();
session_id = Date.now();
}
export { export {
copiedLink,
startedUpload,
cancelledUpload, cancelledUpload,
stoppedUpload, stoppedUpload,
completedUpload, completedUpload,
changedDownloadLimit,
deletedUpload, deletedUpload,
startedDownload,
cancelledDownload,
stoppedDownload, stoppedDownload,
completedDownload, completedDownload,
addedPassword, submittedSignup,
restart, canceledSignup,
unsupported loggedOut
}; };

View File

@ -8,7 +8,7 @@ export default class OwnedFile {
this.url = obj.url; this.url = obj.url;
this.name = obj.name; this.name = obj.name;
this.size = obj.size; this.size = obj.size;
this.type = obj.type; this.manifest = obj.manifest;
this.time = obj.time; this.time = obj.time;
this.speed = obj.speed; this.speed = obj.speed;
this.createdAt = obj.createdAt; this.createdAt = obj.createdAt;
@ -18,6 +18,15 @@ export default class OwnedFile {
this.dtotal = obj.dtotal || 0; this.dtotal = obj.dtotal || 0;
this.keychain = new Keychain(obj.secretKey, obj.nonce); this.keychain = new Keychain(obj.secretKey, obj.nonce);
this._hasPassword = !!obj.hasPassword; this._hasPassword = !!obj.hasPassword;
this.timeLimit = obj.timeLimit;
}
get hasPassword() {
return !!this._hasPassword;
}
get expired() {
return this.dlimit === this.dtotal || Date.now() > this.expiresAt;
} }
async setPassword(password) { async setPassword(password) {
@ -38,19 +47,17 @@ export default class OwnedFile {
return del(this.id, this.ownerToken); return del(this.id, this.ownerToken);
} }
changeLimit(dlimit) { changeLimit(dlimit, user = {}) {
if (this.dlimit !== dlimit) { if (this.dlimit !== dlimit) {
this.dlimit = dlimit; this.dlimit = dlimit;
return setParams(this.id, this.ownerToken, { dlimit }); return setParams(this.id, this.ownerToken, user.bearerToken, { dlimit });
} }
return Promise.resolve(true); return Promise.resolve(true);
} }
get hasPassword() {
return !!this._hasPassword;
}
async updateDownloadCount() { async updateDownloadCount() {
const oldTotal = this.dtotal;
const oldLimit = this.dlimit;
try { try {
const result = await fileInfo(this.id, this.ownerToken); const result = await fileInfo(this.id, this.ownerToken);
this.dtotal = result.dtotal; this.dtotal = result.dtotal;
@ -61,6 +68,7 @@ export default class OwnedFile {
} }
// ignore other errors // ignore other errors
} }
return oldTotal !== this.dtotal || oldLimit !== this.dlimit;
} }
toJSON() { toJSON() {
@ -69,7 +77,7 @@ export default class OwnedFile {
url: this.url, url: this.url,
name: this.name, name: this.name,
size: this.size, size: this.size,
type: this.type, manifest: this.manifest,
time: this.time, time: this.time,
speed: this.speed, speed: this.speed,
createdAt: this.createdAt, createdAt: this.createdAt,
@ -78,7 +86,8 @@ export default class OwnedFile {
ownerToken: this.ownerToken, ownerToken: this.ownerToken,
dlimit: this.dlimit, dlimit: this.dlimit,
dtotal: this.dtotal, dtotal: this.dtotal,
hasPassword: this.hasPassword hasPassword: this.hasPassword,
timeLimit: this.timeLimit
}; };
} }
} }

View File

@ -1,5 +0,0 @@
const html = require('choo/html');
module.exports = function() {
return html`<div></div>`;
};

View File

@ -1,26 +0,0 @@
const html = require('choo/html');
const progress = require('../../templates/progress');
const { fadeOut } = require('../../utils');
module.exports = function(state, emit) {
return html`
<div class="page effect--fadeIn">
<div class="title">
${state.translate('downloadFinish')}
</div>
<div class="description"></div>
${progress(1)}
<div class="progressSection">
<div class="progressSection__text"></div>
</div>
<a class="link link--action"
href="/"
onclick=${sendNew}>${state.translate('sendYourFilesLink')}</a>
</div>`;
async function sendNew(e) {
e.preventDefault();
await fadeOut('.page');
emit('pushState', '/');
}
};

View File

@ -1,42 +0,0 @@
const html = require('choo/html');
const progress = require('../../templates/progress');
const { bytes } = require('../../utils');
module.exports = function(state, emit) {
const transfer = state.transfer;
const cancelBtn = html`
<button
id="cancel"
class="btn btn--cancel"
title="${state.translate('deletePopupCancel')}"
onclick=${cancel}>
${state.translate('deletePopupCancel')}
</button>`;
return html`
<div class="page effect--fadeIn">
<div class="title">
${state.translate('downloadingPageProgress', {
filename: state.fileInfo.name,
size: bytes(state.fileInfo.size)
})}
</div>
<div class="description">
${state.translate('downloadingPageMessage')}
</div>
${progress(transfer.progressRatio, transfer.progressIndefinite)}
<div class="progressSection">
<div class="progressSection__text">
${state.translate(transfer.msg, transfer.sizes)}
</div>
${transfer.state === 'downloading' ? cancelBtn : null}
</div>
</div>
`;
function cancel() {
const btn = document.getElementById('cancel');
btn.remove();
emit('cancel');
}
};

View File

@ -1,10 +0,0 @@
const html = require('choo/html');
const assets = require('../../../common/assets');
module.exports = function(state) {
return html`
<div class="page">
<div class="title">${state.translate('errorPageHeader')}</div>
<img src="${assets.get('illustration_error.svg')}"/>
</div>`;
};

View File

@ -1,32 +0,0 @@
const html = require('choo/html');
const raw = require('choo/html/raw');
module.exports = function(state) {
return html`
<div>
<div class="title">${state.translate('legalHeader')}</div>
${raw(
replaceLinks(state.translate('legalNoticeTestPilot'), [
'https://testpilot.firefox.com/terms',
'https://testpilot.firefox.com/privacy',
'https://testpilot.firefox.com/experiments/send'
])
)}
${raw(
replaceLinks(state.translate('legalNoticeMozilla'), [
'https://www.mozilla.org/privacy/websites/',
'https://www.mozilla.org/about/legal/terms/mozilla/'
])
)}
</div>
`;
};
function replaceLinks(str, urls) {
let i = 0;
const s = str.replace(
/<a>([^<]+)<\/a>/g,
(m, v) => `<a href="${urls[i++]}">${v}</a>`
);
return `<div class="description">${s}</div>`;
}

View File

@ -1,16 +0,0 @@
const html = require('choo/html');
const assets = require('../../../common/assets');
module.exports = function(state) {
return html`
<div class="page">
<div class="title">${state.translate('expiredPageHeader')}</div>
<img src="${assets.get('illustration_expired.svg')}" id="expired-img">
<div class="description">
${state.translate('uploadPageExplainer')}
</div>
<a class="link link--action" href="/">
${state.translate('sendYourFilesLink')}
</a>
</div>`;
};

View File

@ -1,40 +0,0 @@
const html = require('choo/html');
const assets = require('../../../common/assets');
const { bytes } = require('../../utils');
module.exports = function(state, pageAction) {
const fileInfo = state.fileInfo;
const size = fileInfo.size
? state.translate('downloadFileSize', { size: bytes(fileInfo.size) })
: '';
const title = fileInfo.name
? state.translate('downloadFileName', { filename: fileInfo.name })
: state.translate('downloadFileTitle');
const info = html`
<div id="dl-file"
data-nonce="${fileInfo.nonce}"
data-requires-password="${fileInfo.requiresPassword}"></div>`;
if (!pageAction) {
return info;
}
return html`
<div class="page">
<div class="title">
<span>${title}</span>
<span>${' ' + size}</span>
</div>
<div class="description">${state.translate('downloadMessage')}</div>
<img
src="${assets.get('illustration_download.svg')}"
title="${state.translate('downloadAltText')}"/>
${pageAction}
<a class="link link--action" href="/">
${state.translate('sendYourFilesLink')}
</a>
${info}
</div>
`;
};

View File

@ -1,112 +0,0 @@
/* global EXPIRE_SECONDS */
const html = require('choo/html');
const raw = require('choo/html/raw');
const assets = require('../../../common/assets');
const notFound = require('../notFound');
const setPasswordSection = require('../../templates/setPasswordSection');
const selectbox = require('../../templates/selectbox');
const deletePopup = require('../../templates/popup');
const { allowedCopy, delay, fadeOut } = require('../../utils');
module.exports = function(state, emit) {
const file = state.storage.getFileById(state.params.id);
if (!file) {
return notFound(state, emit);
}
return html`
<div id="shareWrapper" class="effect--fadeIn">
${expireInfo(file, state.translate, emit)}
<div class="sharePage">
<div class="sharePage__copyText">
${state.translate('copyUrlFormLabelWithName', { filename: file.name })}
</div>
<div class="copySection">
<input
id="fileUrl"
class="copySection__url"
type="url"
value="${file.url}"
readonly="true"/>
<button id="copyBtn"
class="inputBtn inputBtn--copy"
title="${state.translate('copyUrlFormButton')}"
onclick=${copyLink}>${state.translate('copyUrlFormButton')}</button>
</div>
${setPasswordSection(state, emit)}
<button
class="btn btn--delete"
title="${state.translate('deleteFileButton')}"
onclick=${showPopup}>${state.translate('deleteFileButton')}
</button>
<div class="sharePage__deletePopup">
${deletePopup(
state.translate('deletePopupText'),
state.translate('deletePopupYes'),
state.translate('deletePopupCancel'),
deleteFile
)}
</div>
<a class="link link--action"
href="/"
onclick=${sendNew}>${state.translate('sendAnotherFileLink')}</a>
</div>
</div>
`;
function showPopup() {
const popup = document.querySelector('.popup');
popup.classList.add('popup--show');
popup.focus();
}
async function sendNew(e) {
e.preventDefault();
await fadeOut('#shareWrapper');
emit('pushState', '/');
}
async function copyLink() {
if (allowedCopy()) {
emit('copy', { url: file.url, location: 'success-screen' });
const input = document.getElementById('fileUrl');
input.disabled = true;
input.classList.add('input--copied');
const copyBtn = document.getElementById('copyBtn');
copyBtn.disabled = true;
copyBtn.classList.add('inputBtn--copied');
copyBtn.replaceChild(
html`<img src="${assets.get('check-16.svg')}" class="cursor--pointer">`,
copyBtn.firstChild
);
await delay(2000);
input.disabled = false;
input.classList.remove('input--copied');
copyBtn.disabled = false;
copyBtn.classList.remove('inputBtn--copied');
copyBtn.textContent = state.translate('copyUrlFormButton');
}
}
async function deleteFile() {
emit('delete', { file, location: 'success-screen' });
await fadeOut('#shareWrapper');
emit('pushState', '/');
}
};
function expireInfo(file, translate, emit) {
const hours = Math.floor(EXPIRE_SECONDS / 60 / 60);
const el = html`<div class="title">${raw(
translate('expireInfo', {
downloadCount: '<select></select>',
timespan: translate('timespanHours', { num: hours })
})
)}</div>`;
const select = el.querySelector('select');
const options = [1, 2, 3, 4, 5, 20].filter(i => i > (file.dtotal || 0));
const t = num => translate('downloadCount', { num });
const changed = value => emit('changeLimit', { file, value });
el.replaceChild(selectbox(file.dlimit || 1, options, t, changed), select);
return el;
}

View File

@ -1,112 +0,0 @@
.sharePage {
margin: 0 auto;
display: flex;
justify-content: center;
flex-direction: column;
width: 100%;
max-width: 640px;
}
.sharePage__copyText {
align-self: flex-start;
margin-top: 60px;
margin-bottom: 10px;
color: var(--textColor);
max-width: 614px;
word-wrap: break-word;
}
.sharePage__deletePopup {
position: relative;
align-self: center;
bottom: 50px;
}
.copySection {
display: flex;
flex-wrap: nowrap;
width: 100%;
}
.copySection__url {
flex: 1;
height: 56px;
border: 1px solid var(--primaryControlBGColor);
border-radius: 6px 0 0 6px;
font-size: 20px;
color: var(--inputTextColor);
font-family: 'SF Pro Text', sans-serif;
letter-spacing: 0;
line-height: 23px;
font-weight: 300;
padding-left: 10px;
}
.copySection__url:disabled {
border: 1px solid var(--successControlBGColor);
background: var(--successControlFGColor);
}
.inputBtn--copy {
flex: 0 1 165px;
padding-bottom: 4px;
}
.input--copied {
border-color: var(--successControlBGColor);
}
.inputBtn--copied,
.inputBtn--copied:hover {
background: var(--successControlBGColor);
border: 1px solid var(--successControlBGColor);
color: var(--successControlFGColor);
}
.btn--delete {
align-self: center;
width: 176px;
height: 44px;
background: #fff;
border-color: rgba(12, 12, 13, 0.3);
margin-top: 50px;
margin-bottom: 12px;
color: #313131;
}
.btn--delete:hover {
background: #efeff1;
}
@media (max-device-width: 768px), (max-width: 768px) {
.copySection {
width: 100%;
}
.copySection__url {
font-size: 18px;
}
}
@media (max-device-width: 520px), (max-width: 520px) {
.copySection {
width: 100%;
flex-direction: column;
padding-left: 0;
}
.copySection__url {
font-size: 22px;
padding: 15px 10px;
border-radius: 6px 6px 0 0;
}
.sharePage__copyText {
text-align: center;
}
.inputBtn--copy {
border-radius: 0 0 6px 6px;
flex: 0 1 65px;
}
}

View File

@ -1,67 +0,0 @@
const html = require('choo/html');
const assets = require('../../../common/assets');
module.exports = function(state) {
let strings = {};
let why = '';
let url = '';
let buttonAction = '';
if (state.params.reason !== 'outdated') {
strings = unsupportedStrings(state);
why = html`
<div class="description">
<a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">
${state.translate('notSupportedLink')}
</a>
</div>`;
url =
'https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com';
buttonAction = html`
<div class="firefoxDownload__action">
Firefox<br><span class="firefoxDownload__text">${strings.button}</span>
</div>`;
} else {
strings = outdatedStrings(state);
url = 'https://support.mozilla.org/kb/update-firefox-latest-version';
buttonAction = html`
<div class="firefoxDownload__action">
${strings.button}
</div>`;
}
return html`
<div class="unsupportedPage">
<div class="title">${strings.title}</div>
<div class="description">
${strings.description}
</div>
${why}
<a href="${url}" class="firefoxDownload">
<img
src="${assets.get('firefox_logo-only.svg')}"
class="firefoxDownload__logo"
alt="Firefox"/>
${buttonAction}
</a>
<div class="unsupportedPage__info">
${strings.explainer}
</div>
</div>`;
};
function outdatedStrings(state) {
return {
title: state.translate('notSupportedHeader'),
description: state.translate('notSupportedOutdatedDetail'),
button: state.translate('updateFirefox'),
explainer: state.translate('uploadPageExplainer')
};
}
function unsupportedStrings(state) {
return {
title: state.translate('notSupportedHeader'),
description: state.translate('notSupportedDetail'),
button: state.translate('downloadFirefoxButtonSub'),
explainer: state.translate('uploadPageExplainer')
};
}

View File

@ -1,49 +0,0 @@
.unsupportedPage {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.unsupportedPage__info {
font-size: 13px;
line-height: 23px;
text-align: center;
color: var(--lightTextColor);
margin: 0 auto 23px;
}
.firefoxDownload {
margin-bottom: 181px;
height: 80px;
background: #98e02b;
border-radius: 3px;
cursor: pointer;
border: 0;
box-shadow: 0 5px 3px rgb(234, 234, 234);
font-family: 'Fira Sans', 'segoe ui', sans-serif;
font-weight: 500;
color: var(--primaryControlFGColor);
font-size: 26px;
display: flex;
justify-content: center;
align-items: center;
line-height: 1;
padding: 0 25px;
}
.firefoxDownload__logo {
width: 70px;
}
.firefoxDownload__action {
text-align: left;
margin-left: 20.4px;
}
.firefoxDownload__text {
font-family: 'Fira Sans', 'segoe ui', sans-serif;
font-weight: 300;
font-size: 18px;
letter-spacing: -0.69px;
}

View File

@ -1,39 +0,0 @@
const html = require('choo/html');
const progress = require('../../templates/progress');
const { bytes } = require('../../utils');
module.exports = function(state, emit) {
const transfer = state.transfer;
return html`
<div class="page effect--fadeIn">
<div class="title">
${state.translate('uploadingPageProgress', {
filename: transfer.file.name,
size: bytes(transfer.file.size)
})}
</div>
<div class="description"></div>
${progress(transfer.progressRatio, transfer.progressIndefinite)}
<div class="progressSection">
<div class="progressSection__text">
${state.translate(transfer.msg, transfer.sizes)}
</div>
<button
id="cancel-upload"
class="btn btn--cancel"
title="${state.translate('uploadingPageCancel')}"
onclick=${cancel}>
${state.translate('uploadingPageCancel')}
</button>
</div>
</div>
`;
function cancel() {
const btn = document.getElementById('cancel-upload');
btn.disabled = true;
btn.textContent = state.translate('uploadCancelNotification');
emit('cancel');
}
};

View File

@ -1,84 +0,0 @@
/* global MAXFILESIZE */
const html = require('choo/html');
const assets = require('../../../common/assets');
const fileList = require('../../templates/fileList');
const { bytes, fadeOut } = require('../../utils');
module.exports = function(state, emit) {
// the page flickers if both the server and browser set 'effect--fadeIn'
const fade = state.layout ? '' : 'effect--fadeIn';
return html`
<div id="page-one" class="${fade}">
<div class="title">${state.translate('uploadPageHeader')}</div>
<div class="description">
<div>${state.translate('uploadPageExplainer')}</div>
<a
href="https://testpilot.firefox.com/experiments/send"
class="link">
${state.translate('uploadPageLearnMore')}
</a>
</div>
<div class="uploadArea"
ondragover=${dragover}
ondragleave=${dragleave}>
<img
src="${assets.get('upload.svg')}"
title="${state.translate('uploadSvgAlt')}"/>
<div class="uploadArea__msg">
${state.translate('uploadPageDropMessage')}
</div>
<span class="uploadArea__sizeMsg">
${state.translate('uploadPageSizeMessage')}
</span>
<input id="file-upload"
class="inputFile"
type="file"
name="fileUploaded"
onfocus=${onfocus}
onblur=${onblur}
onchange=${upload} />
<label for="file-upload"
class="btn btn--file"
title="${state.translate('uploadPageBrowseButton1')}">
${state.translate('uploadPageBrowseButton1')}
</label>
</div>
${fileList(state, emit)}
</div>
`;
function dragover(event) {
const div = document.querySelector('.uploadArea');
div.classList.add('uploadArea--dragging');
}
function dragleave(event) {
const div = document.querySelector('.uploadArea');
div.classList.remove('uploadArea--dragging');
}
function onfocus(event) {
event.target.classList.add('inputFile--focused');
}
function onblur(event) {
event.target.classList.remove('inputFile--focused');
}
async function upload(event) {
event.preventDefault();
const target = event.target;
const file = target.files[0];
if (file.size === 0) {
return;
}
if (file.size > MAXFILESIZE) {
// eslint-disable-next-line no-alert
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
return;
}
await fadeOut('#page-one');
emit('upload', { file, type: 'click' });
}
};

View File

@ -1,65 +0,0 @@
.uploadArea {
border: 3px dashed rgba(0, 148, 251, 0.5);
margin: 0 auto 10px;
height: 255px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
transition: transform 150ms;
padding: 15px;
}
.uploadArea__msg {
font-size: 22px;
color: var(--lightTextColor);
margin: 20px 0 10px;
font-family: 'SF Pro Text', sans-serif;
}
.uploadArea__sizeMsg {
font-style: italic;
font-size: 12px;
line-height: 16px;
color: var(--lightTextColor);
margin-bottom: 22px;
}
.uploadArea--dragging {
border: 5px dashed rgba(0, 148, 251, 0.5);
height: 251px;
transform: scale(1.04);
border-radius: 4.2px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
}
.uploadArea--dragging * {
pointer-events: none;
}
.btn--file {
font-size: 20px;
min-width: 240px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
padding: 0 10px;
}
.inputFile {
opacity: 0;
position: absolute;
}
.inputFile--focused + .btn--file {
background-color: var(--primaryControlHoverColor);
outline: 1px dotted #000;
outline: -webkit-focus-ring-color auto 5px;
}

View File

@ -1,25 +1,36 @@
/* global MAXFILESIZE */ function getString(item) {
import { bytes } from './utils'; return new Promise(resolve => {
item.getAsString(resolve);
});
}
export default function(state, emitter) { export default function(state, emitter) {
window.addEventListener('paste', event => { window.addEventListener('paste', async event => {
if (state.route !== '/' || state.uploading) return; if (state.route !== '/' || state.uploading) return;
if (['password', 'text'].includes(event.target.type)) return;
for (const item of event.clipboardData.items) { const items = Array.from(event.clipboardData.items);
if (!item.type.includes('image')) continue; const transferFiles = items.filter(item => item.kind === 'file');
const strings = items.filter(item => item.kind === 'string');
const file = item.getAsFile(); if (transferFiles.length) {
const promises = transferFiles.map(async (f, i) => {
if (!file) continue; // Sometimes null const blob = f.getAsFile();
if (!blob) {
if (file.size > MAXFILESIZE) { return null;
// eslint-disable-next-line no-alert }
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) })); const name = await getString(strings[i]);
continue; const file = new File([blob], name, { type: blob.type });
return file;
});
const files = (await Promise.all(promises)).filter(f => !!f);
if (files.length) {
emitter.emit('addFiles', { files });
} }
} else if (strings.length) {
emitter.emit('upload', { file, type: 'paste' }); strings[0].getAsString(s => {
return; // return here since only one file is allowed to be uploaded at a time const file = new File([s], 'pasted.txt', { type: 'text/plain' });
emitter.emit('addFiles', { files: [file] });
});
} }
}); });
} }

18
app/routes.js Normal file
View File

@ -0,0 +1,18 @@
const choo = require('choo');
const download = require('./ui/download');
const body = require('./ui/body');
module.exports = function(app = choo()) {
app.route('/', body(require('./ui/home')));
app.route('/download/:id', body(download));
app.route('/download/:id/:key', body(download));
app.route('/unsupported/:reason', body(require('./ui/unsupported')));
app.route('/legal', body(require('./ui/legal')));
app.route('/error', body(require('./ui/error')));
app.route('/blank', body(require('./ui/blank')));
app.route('/oauth', function(state, emit) {
emit('authenticate', state.query.code, state.query.state);
});
app.route('*', body(require('./ui/notFound')));
return app;
};

View File

@ -1,60 +0,0 @@
const preview = require('../pages/preview');
const download = require('../pages/download');
const notFound = require('../pages/notFound');
const downloadPassword = require('../templates/downloadPassword');
const downloadButton = require('../templates/downloadButton');
function hasFileInfo() {
return !!document.getElementById('dl-file');
}
function getFileInfoFromDOM() {
const el = document.getElementById('dl-file');
if (!el) {
return null;
}
return {
nonce: el.getAttribute('data-nonce'),
requiresPassword: !!+el.getAttribute('data-requires-password')
};
}
function createFileInfo(state) {
const metadata = getFileInfoFromDOM();
return {
id: state.params.id,
secretKey: state.params.key,
nonce: metadata.nonce,
requiresPassword: metadata.requiresPassword
};
}
module.exports = function(state, emit) {
if (!state.fileInfo) {
// This is a fresh page load
// We need to parse the file info from the server's html
if (!hasFileInfo()) {
return notFound(state, emit);
}
state.fileInfo = createFileInfo(state);
if (!state.fileInfo.requiresPassword) {
emit('getMetadata');
}
}
let pageAction = null; //default state: we don't have file metadata
if (state.transfer) {
const s = state.transfer.state;
if (['downloading', 'decrypting', 'complete'].indexOf(s) > -1) {
// Downloading is in progress
return download(state, emit);
}
// we have file metadata
pageAction = downloadButton(state, emit);
} else if (state.fileInfo.requiresPassword && !state.fileInfo.password) {
// we're waiting on the user for a valid password
pageAction = downloadPassword(state, emit);
}
return preview(state, pageAction);
};

View File

@ -1,9 +0,0 @@
const welcome = require('../pages/welcome');
const upload = require('../pages/upload');
module.exports = function(state, emit) {
if (state.uploading) {
return upload(state, emit);
}
return welcome(state, emit);
};

View File

@ -1,58 +0,0 @@
const choo = require('choo');
const html = require('choo/html');
const nanotiming = require('nanotiming');
const download = require('./download');
const header = require('../templates/header');
const footer = require('../templates/footer');
const fxPromo = require('../templates/fxPromo');
nanotiming.disabled = true;
const app = choo();
function banner(state, emit) {
if (state.promo && !state.route.startsWith('/unsupported/')) {
return fxPromo(state, emit);
}
}
function body(template) {
return function(state, emit) {
const b = html`<body>
${banner(state, emit)}
${header(state)}
<main class="main">
<noscript>
<div class="noscript">
<h2>${state.translate('javascriptRequired')}</h2>
<p>
<a class="link" href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">
${state.translate('whyJavascript')}
</a>
</p>
<p>${state.translate('enableJavascript')}</p>
</div>
</noscript>
${template(state, emit)}
</main>
${footer(state)}
</body>`;
if (state.layout) {
// server side only
return state.layout(state, b);
}
return b;
};
}
app.route('/', body(require('./home')));
app.route('/share/:id', body(require('../pages/share')));
app.route('/download/:id', body(download));
app.route('/download/:id/:key', body(download));
app.route('/completed', body(require('../pages/completed')));
app.route('/unsupported/:reason', body(require('../pages/unsupported')));
app.route('/legal', body(require('../pages/legal')));
app.route('/error', body(require('../pages/error')));
app.route('/blank', body(require('../pages/blank')));
app.route('*', body(require('../pages/notFound')));
module.exports = app;

161
app/serviceWorker.js Normal file
View File

@ -0,0 +1,161 @@
import assets from '../common/assets';
import { version } from '../package.json';
import Keychain from './keychain';
import { downloadStream } from './api';
import { transformStream } from './streams';
import Zip from './zip';
import contentDisposition from 'content-disposition';
let noSave = false;
const map = new Map();
const IMAGES = /.*\.(png|svg|jpg)$/;
const VERSIONED_ASSET = /\.[A-Fa-f0-9]{8}\.(js|css|png|svg|jpg)$/;
const DOWNLOAD_URL = /\/api\/download\/([A-Fa-f0-9]{4,})/;
self.addEventListener('install', event => {
event.waitUntil(precache());
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
async function decryptStream(id) {
const file = map.get(id);
if (!file) {
return new Response(null, { status: 400 });
}
try {
let size = file.size;
let type = file.type;
const keychain = new Keychain(file.key, file.nonce);
if (file.requiresPassword) {
keychain.setPassword(file.password, file.url);
}
file.download = downloadStream(id, keychain);
const body = await file.download.result;
const decrypted = keychain.decryptStream(body);
let zipStream = null;
if (file.type === 'send-archive') {
const zip = new Zip(file.manifest, decrypted);
zipStream = zip.stream;
type = 'application/zip';
size = zip.size;
}
const responseStream = transformStream(
zipStream || decrypted,
{
transform(chunk, controller) {
file.progress += chunk.length;
controller.enqueue(chunk);
}
},
function oncancel() {
// NOTE: cancel doesn't currently fire on chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=638494
file.download.cancel();
map.delete(id);
}
);
const headers = {
'Content-Disposition': contentDisposition(file.filename),
'Content-Type': type,
'Content-Length': size
};
return new Response(responseStream, { headers });
} catch (e) {
if (noSave) {
return new Response(null, { status: e.message });
}
return new Response(null, {
status: 302,
headers: {
Location: `/download/${id}/#${file.key}`
}
});
}
}
async function precache() {
const oldCaches = await caches.keys();
for (const c of oldCaches) {
if (c !== version) {
await caches.delete(c);
}
}
const cache = await caches.open(version);
const images = assets.match(IMAGES);
await cache.addAll(images);
return self.skipWaiting();
}
async function cachedOrFetched(req) {
const cache = await caches.open(version);
const cached = await cache.match(req);
if (cached) {
return cached;
}
const fetched = await fetch(req);
if (fetched.ok && VERSIONED_ASSET.test(req.url)) {
cache.put(req, fetched.clone());
}
return fetched;
}
self.onfetch = event => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
const dlmatch = DOWNLOAD_URL.exec(url.pathname);
if (dlmatch) {
event.respondWith(decryptStream(dlmatch[1]));
} else if (VERSIONED_ASSET.test(url.pathname)) {
event.respondWith(cachedOrFetched(req));
}
};
self.onmessage = event => {
if (event.data.request === 'init') {
noSave = event.data.noSave;
const info = {
key: event.data.key,
nonce: event.data.nonce,
filename: event.data.filename,
requiresPassword: event.data.requiresPassword,
password: event.data.password,
url: event.data.url,
type: event.data.type,
manifest: event.data.manifest,
size: event.data.size,
progress: 0
};
map.set(event.data.id, info);
event.ports[0].postMessage('file info received');
} else if (event.data.request === 'progress') {
const file = map.get(event.data.id);
if (!file) {
event.ports[0].postMessage({ error: 'cancelled' });
} else {
if (file.progress === file.size) {
map.delete(event.data.id);
}
event.ports[0].postMessage({ progress: file.progress });
}
} else if (event.data.request === 'cancel') {
const file = map.get(event.data.id);
if (file) {
if (file.download) {
file.download.cancel();
}
map.delete(event.data.id);
}
event.ports[0].postMessage('download cancelled');
}
};

View File

@ -1,4 +1,4 @@
import { isFile } from './utils'; import { arrayToB64, isFile } from './utils';
import OwnedFile from './ownedFile'; import OwnedFile from './ownedFile';
class Mem { class Mem {
@ -38,7 +38,7 @@ class Storage {
} }
loadFiles() { loadFiles() {
const fs = []; const fs = new Map();
for (let i = 0; i < this.engine.length; i++) { for (let i = 0; i < this.engine.length; i++) {
const k = this.engine.key(i); const k = this.engine.key(i);
if (isFile(k)) { if (isFile(k)) {
@ -47,14 +47,24 @@ class Storage {
if (!f.id) { if (!f.id) {
f.id = f.fileId; f.id = f.fileId;
} }
fs.push(f);
fs.set(f.id, f);
} catch (err) { } catch (err) {
// obviously you're not a golfer // obviously you're not a golfer
this.engine.removeItem(k); this.engine.removeItem(k);
} }
} }
} }
return fs.sort((a, b) => a.createdAt - b.createdAt); return fs;
}
get id() {
let id = this.engine.getItem('device_id');
if (!id) {
id = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
this.engine.setItem('device_id', id);
}
return id;
} }
get totalDownloads() { get totalDownloads() {
@ -89,26 +99,44 @@ class Storage {
} }
get files() { get files() {
return this._files; return Array.from(this._files.values()).sort(
(a, b) => a.createdAt - b.createdAt
);
}
get user() {
try {
return JSON.parse(this.engine.getItem('user'));
} catch (e) {
return null;
}
}
set user(info) {
return this.engine.setItem('user', JSON.stringify(info));
} }
getFileById(id) { getFileById(id) {
return this._files.find(f => f.id === id); return this._files.get(id);
} }
get(id) { get(id) {
return this.engine.getItem(id); return this.engine.getItem(id);
} }
set(id, value) {
return this.engine.setItem(id, value);
}
remove(property) { remove(property) {
if (isFile(property)) { if (isFile(property)) {
this._files.splice(this._files.findIndex(f => f.id === property), 1); this._files.delete(property);
} }
this.engine.removeItem(property); this.engine.removeItem(property);
} }
addFile(file) { addFile(file) {
this._files.push(file); this._files.set(file.id, file);
this.writeFile(file); this.writeFile(file);
} }
@ -119,6 +147,42 @@ class Storage {
writeFiles() { writeFiles() {
this._files.forEach(f => this.writeFile(f)); this._files.forEach(f => this.writeFile(f));
} }
clearLocalFiles() {
this._files.forEach(f => this.engine.removeItem(f.id));
this._files = new Map();
}
async merge(files = []) {
let incoming = false;
let outgoing = false;
let downloadCount = false;
for (const f of files) {
if (!this.getFileById(f.id)) {
this.addFile(new OwnedFile(f));
incoming = true;
}
}
const workingFiles = this.files.slice();
for (const f of workingFiles) {
const cc = await f.updateDownloadCount();
if (cc) {
await this.writeFile(f);
}
downloadCount = downloadCount || cc;
outgoing = outgoing || f.expired;
if (f.expired) {
this.remove(f.id);
} else if (!files.find(x => x.id === f.id)) {
outgoing = true;
}
}
return {
incoming,
outgoing,
downloadCount
};
}
} }
export default new Storage(); export default new Storage();

103
app/streams.js Normal file
View File

@ -0,0 +1,103 @@
/* global ReadableStream TransformStream */
export function transformStream(readable, transformer, oncancel) {
try {
return readable.pipeThrough(new TransformStream(transformer));
} catch (e) {
const reader = readable.getReader();
return new ReadableStream({
start(controller) {
if (transformer.start) {
return transformer.start(controller);
}
},
async pull(controller) {
let enqueued = false;
const wrappedController = {
enqueue(d) {
enqueued = true;
controller.enqueue(d);
}
};
while (!enqueued) {
const data = await reader.read();
if (data.done) {
if (transformer.flush) {
await transformer.flush(controller);
}
return controller.close();
}
await transformer.transform(data.value, wrappedController);
}
},
cancel(reason) {
readable.cancel(reason);
if (oncancel) {
oncancel(reason);
}
}
});
}
}
class BlobStreamController {
constructor(blob, size) {
this.blob = blob;
this.index = 0;
this.chunkSize = size || 1024 * 64;
}
pull(controller) {
return new Promise((resolve, reject) => {
const bytesLeft = this.blob.size - this.index;
if (bytesLeft <= 0) {
controller.close();
return resolve();
}
const size = Math.min(this.chunkSize, bytesLeft);
const slice = this.blob.slice(this.index, this.index + size);
const reader = new FileReader();
reader.onload = () => {
controller.enqueue(new Uint8Array(reader.result));
resolve();
};
reader.onerror = reject;
reader.readAsArrayBuffer(slice);
this.index += size;
});
}
}
export function blobStream(blob, size) {
return new ReadableStream(new BlobStreamController(blob, size));
}
class ConcatStreamController {
constructor(streams) {
this.streams = streams;
this.index = 0;
this.reader = null;
this.nextReader();
}
nextReader() {
const next = this.streams[this.index++];
this.reader = next && next.getReader();
}
async pull(controller) {
if (!this.reader) {
return controller.close();
}
const data = await this.reader.read();
if (data.done) {
this.nextReader();
return this.pull(controller);
}
controller.enqueue(data.value);
}
}
export function concatStream(streams) {
return new ReadableStream(new ConcatStreamController(streams));
}

View File

@ -1,6 +0,0 @@
.btn--download {
width: 180px;
height: 44px;
margin-top: 20px;
margin-bottom: 30px;
}

View File

@ -1,13 +0,0 @@
const html = require('choo/html');
module.exports = function(state, emit) {
return html`
<button class="btn btn--download"
onclick=${download}>${state.translate('downloadButtonLabel')}
</button>`;
function download(event) {
event.preventDefault();
emit('download', state.fileInfo);
}
};

View File

@ -1,22 +0,0 @@
.passwordSection {
text-align: left;
padding: 40px 0;
width: 80%;
}
.passwordForm {
display: flex;
flex-wrap: nowrap;
width: 100%;
padding: 10px 0;
}
@media (max-device-width: 520px), (max-width: 520px) {
.passwordSection {
width: 100%;
}
.passwordForm {
flex-direction: column;
}
}

Some files were not shown because too many files have changed in this diff Show More