Compare commits
No commits in common. "master" and "v2.4.1" have entirely different histories.
|
@ -1,8 +1,10 @@
|
|||
.circleci
|
||||
.nyc_output
|
||||
.vscode
|
||||
node_modules
|
||||
.git
|
||||
.DS_Store
|
||||
coverage
|
||||
docs
|
||||
firefox
|
||||
node_modules
|
||||
assets
|
||||
docs
|
||||
public
|
||||
test
|
||||
coverage
|
||||
.nyc_output
|
|
@ -2,7 +2,3 @@ dist
|
|||
assets
|
||||
firefox
|
||||
coverage
|
||||
android/app/build
|
||||
app/locale.js
|
||||
app/capabilities.js
|
||||
app/qrcode.js
|
|
@ -4,7 +4,6 @@ env:
|
|||
|
||||
extends:
|
||||
- eslint:recommended
|
||||
- prettier
|
||||
- plugin:node/recommended
|
||||
- plugin:security/recommended
|
||||
|
||||
|
@ -15,14 +14,18 @@ plugins:
|
|||
root: true
|
||||
|
||||
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-import: off
|
||||
|
||||
security/detect-non-literal-fs-filename: off
|
||||
security/detect-object-injection: off
|
||||
|
||||
eol-last: [error, always]
|
||||
eqeqeq: error
|
||||
no-alert: warn
|
||||
no-console: warn
|
||||
no-path-concat: error
|
||||
no-unused-vars: [error, {argsIgnorePattern: "^_|err|event|next|reject"}]
|
||||
require-atomic-updates: warn
|
||||
no-var: error
|
||||
one-var: [error, never]
|
||||
prefer-const: error
|
||||
quotes: [error, single, {avoidEscape: true}]
|
||||
|
|
4
.gitattributes
vendored
|
@ -1,2 +1,2 @@
|
|||
public/locales/*/*.ftl linguist-documentation
|
||||
docs/** linguist-documentation
|
||||
public/locales/* linguist-documentation
|
||||
docs/* linguist-documentation
|
||||
|
|
12
.gitignore
vendored
|
@ -1,16 +1,6 @@
|
|||
node_modules
|
||||
coverage
|
||||
dist
|
||||
.env
|
||||
.idea
|
||||
.DS_Store
|
||||
.nyc_output
|
||||
.tox
|
||||
.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
|
||||
.nyc_output
|
|
@ -1,72 +0,0 @@
|
|||
|
||||
stages:
|
||||
- test
|
||||
- release
|
||||
|
||||
# Build Send, run npm tests
|
||||
test:
|
||||
stage: test
|
||||
image: "node:16-slim"
|
||||
only:
|
||||
- api
|
||||
- branches
|
||||
- chat
|
||||
- merge_requests
|
||||
- pushes
|
||||
- schedules
|
||||
- tags
|
||||
- triggers
|
||||
- web
|
||||
before_script:
|
||||
# Install dependencies
|
||||
- apt-get update
|
||||
- apt-get install -y git python3 build-essential libxtst6
|
||||
|
||||
# Prepare Chrome for puppeteer
|
||||
- apt-get install -y wget gnupg
|
||||
- wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
|
||||
- sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
||||
- apt-get update
|
||||
- apt-get install -y gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils
|
||||
- apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends
|
||||
script:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
- npm test
|
||||
|
||||
release-docker:
|
||||
stage: release
|
||||
image: docker:latest
|
||||
services:
|
||||
- docker:dind
|
||||
only:
|
||||
- api
|
||||
- branches
|
||||
- chat
|
||||
- merge_requests
|
||||
- pushes
|
||||
- schedules
|
||||
- tags
|
||||
- triggers
|
||||
- web
|
||||
script:
|
||||
- docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
|
||||
- docker buildx create --name sendBuilder
|
||||
- docker buildx use sendBuilder
|
||||
- |
|
||||
if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
IMAGE_NAMES="$CI_REGISTRY_IMAGE/mr:$CI_MERGE_REQUEST_IID"
|
||||
elif [ "$CI_COMMIT_TAG" != "" ]; then
|
||||
IMAGE_NAMES="$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG $CI_REGISTRY_IMAGE:latest"
|
||||
else
|
||||
IMAGE_NAMES="$CI_REGISTRY_IMAGE/$CI_COMMIT_BRANCH:$CI_COMMIT_SHORT_SHA"
|
||||
fi
|
||||
- |
|
||||
for image in $IMAGE_NAMES; do
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t $image . --push
|
||||
done
|
||||
- |
|
||||
echo "Container image pushed. You can pull it with";
|
||||
for image in $IMAGE_NAMES; do
|
||||
echo "docker pull $image"
|
||||
done
|
|
@ -1,4 +1,3 @@
|
|||
dist
|
||||
android/app/src/main/assets
|
||||
android/app/build
|
||||
assets/*.js
|
||||
coverage
|
|
@ -10,6 +10,3 @@ rules:
|
|||
declaration-colon-newline-after: null
|
||||
selector-list-comma-newline-after: null
|
||||
value-list-comma-newline-after: null
|
||||
at-rule-no-unknown: null
|
||||
# Conflicts with prettier
|
||||
string-quotes: null
|
||||
|
|
13
CHANGELOG.md
|
@ -1,17 +1,6 @@
|
|||
## Change Log
|
||||
|
||||
### v2.5.1 (2018/03/12 19:26 +00:00)
|
||||
- [#789](https://github.com/mozilla/send/pull/789) Fixed #775 : Made text not-selectable (@RCMainak)
|
||||
|
||||
### v2.5.0 (2018/03/08 19:31 +00:00)
|
||||
- [#782](https://github.com/mozilla/send/pull/782) updated docs (@dannycoates)
|
||||
- [#781](https://github.com/mozilla/send/pull/781) Don't translate URL-safe chars, b64 is doing it for us (@timvisee)
|
||||
- [#779](https://github.com/mozilla/send/pull/779) implemented crypto polyfills for ms edge (@dannycoates)
|
||||
|
||||
### v2.4.1 (2018/02/28 17:05 +00:00)
|
||||
- [#777](https://github.com/mozilla/send/pull/777) use a separate circle in the progress svg for indefinite progress (@dannycoates)
|
||||
|
||||
### v2.4.0 (2018/02/27 01:55 +00:00)
|
||||
### upcoming (2018/02/27 01:52 +00:00)
|
||||
- [#769](https://github.com/mozilla/send/pull/769) removed unsafe-inline styles via svgo-loader (@dannycoates)
|
||||
- [#767](https://github.com/mozilla/send/pull/767) added coverage artifact to circleci (@dannycoates)
|
||||
- [#766](https://github.com/mozilla/send/pull/766) Some frontend unit tests [WIP] (@dannycoates)
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
# Community Participation Guidelines
|
||||
|
||||
This repository is governed by Mozilla's code of conduct and etiquette guidelines.
|
||||
For more details, please read the
|
||||
[Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/).
|
||||
|
||||
## How to Report
|
||||
For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page.
|
||||
|
||||
<!--
|
||||
## Project Specific Etiquette
|
||||
|
||||
In some cases, there will be additional project etiquette i.e.: (https://bugzilla.mozilla.org/page.cgi?id=etiquette.html).
|
||||
Please update for your project.
|
||||
-->
|
182
CONTRIBUTORS
|
@ -1,203 +1,95 @@
|
|||
Abd ar-Rahman Hamidi
|
||||
Abdalrahman Hwoij
|
||||
Abdulrash6211
|
||||
Abdulrasheed Idris
|
||||
Abelardo Ayala Rodríguez
|
||||
Abhinav Adduri
|
||||
Adaobi
|
||||
Adnan Kičin
|
||||
Adolfo Jayme Barrientos
|
||||
Alberto Castro
|
||||
Alexander Parada
|
||||
Alexander Slovesnik
|
||||
Alfredos-Panagiotis Damkalis
|
||||
Aman Alam
|
||||
Amin Mahmudian
|
||||
Ander Elortondo
|
||||
Andreas Pettersson
|
||||
Anesu Chiodza
|
||||
Anika Dorn
|
||||
Anish Sheela
|
||||
Arash Mousavi
|
||||
Artem Polivanchuk
|
||||
Ashesh Vidyut
|
||||
Ashikur Rahman
|
||||
Ashok kumar
|
||||
Ayobamiadebayo375
|
||||
Balasankar C
|
||||
Bald3mar
|
||||
Balázs Meskó
|
||||
Belayet Hossain
|
||||
Benjamin Forehand Jr
|
||||
Besnik Bleta
|
||||
Björn I
|
||||
Bjørn I
|
||||
Boopesh Mahendran
|
||||
Brahim Essaidi
|
||||
Brainlulz
|
||||
Breana Gonzales
|
||||
CLASSIFIED
|
||||
Christian Elbrianno
|
||||
Christoph Kührer
|
||||
Christopher Ramírez
|
||||
Chuck Harmston
|
||||
Cloney 173741
|
||||
Cláudio Esperança
|
||||
Connor Ford
|
||||
Cristian Silaghi
|
||||
Cynthia Pereira
|
||||
Daniel Thorn
|
||||
Daniela Arcese
|
||||
Danny Coates
|
||||
David Dumas
|
||||
Davide
|
||||
Derek Tamsen
|
||||
Dhyey Thakore
|
||||
Donovan Preston
|
||||
Edi Santoso
|
||||
Edmund Huggett
|
||||
Eduard Bopp
|
||||
Elisa X
|
||||
Emily
|
||||
Emily Hou
|
||||
Emin Mastizada
|
||||
Enol
|
||||
Erica
|
||||
Erica Wright
|
||||
Fauzan Alfi
|
||||
Filip Hruška
|
||||
Fjoerfoks
|
||||
Francesco Lodolo
|
||||
Francesco Lodolo [:flod]
|
||||
Frederick Villaluna
|
||||
G12r
|
||||
Gabriela
|
||||
Garysqo
|
||||
Gautam krishna.R
|
||||
George Raptis
|
||||
Georgianizator
|
||||
Gery Escalier
|
||||
Gisela Solis
|
||||
Gonçalo Matos
|
||||
Gwenn
|
||||
Hampus
|
||||
Hmxhmx
|
||||
Hrant
|
||||
Hugo
|
||||
Hugo Abreu
|
||||
Hyeonseok Shin
|
||||
Håvar Henriksen
|
||||
Ian Neal
|
||||
ItielMaN
|
||||
Jae Hyeon Park
|
||||
Jakob Kappel
|
||||
Jakub Rychlý
|
||||
Jamie
|
||||
Jan Schloß
|
||||
Jarmo
|
||||
Jim Spentzos
|
||||
Jiri Grönroos
|
||||
Jirka Soukeník
|
||||
Jobava
|
||||
Joe Becher
|
||||
Joe ST
|
||||
Joergen
|
||||
Johann-S
|
||||
John Gruen
|
||||
John Zonunmawi Vankal
|
||||
Jon Buckley
|
||||
Jon Vadillo
|
||||
Jonathan Claudius
|
||||
Jordi Cuevas
|
||||
Jordi Serratosa
|
||||
Joseph.maza
|
||||
José Manuel
|
||||
Juan Esteban Ajsivinac Sián
|
||||
Juan Pablo
|
||||
Juan Sián
|
||||
Julio Gomez
|
||||
Juraj Cigáň
|
||||
Jwtiyar
|
||||
Kerim Kalamujić
|
||||
Khaled Hosny
|
||||
Kim Ludvigsen
|
||||
Kim YoungCheon
|
||||
Kim Younggeon
|
||||
Kohei Yoshino
|
||||
Lan Glad
|
||||
Lasse Liehu
|
||||
Laurent Jouanneau
|
||||
Lobodzets
|
||||
LuFlo
|
||||
Luis A. Sánchez
|
||||
Luis Flores Martínez
|
||||
Luiz Carlos de Morais
|
||||
Luiz Felipe F M Costa
|
||||
Luna Jernberg
|
||||
Mahay Alam Khan
|
||||
Manuela Silva
|
||||
Marcelo Ghelman
|
||||
Marcelo Poli
|
||||
Marco Aurélio
|
||||
Mark Heijl
|
||||
Mark Liang
|
||||
Mark Liang (You-Wen)
|
||||
Marko Andrejić
|
||||
Martijn Dekker
|
||||
Marwan Mohamad
|
||||
Mathieu Lecarme
|
||||
Matjaž Horvat
|
||||
Maykon Chagas
|
||||
Melo46
|
||||
Merike Sell
|
||||
Michael Köhler
|
||||
Michael Peter
|
||||
Michael Wolf
|
||||
Michal Stanke
|
||||
Michal Vašíček
|
||||
Miguel
|
||||
Mikeyy
|
||||
Milo
|
||||
Miro Rauhala
|
||||
Misael Hernández
|
||||
Mozilla Pontoon
|
||||
Mozilla-GitHub-Standards
|
||||
Mozinet
|
||||
Moḥend Belqasem
|
||||
Muhend Belkacem
|
||||
Muḥend Belqasem
|
||||
Myungjae Won
|
||||
Netza López
|
||||
Nicholas Skinsacos
|
||||
Nihad
|
||||
Nihad Suljić
|
||||
Niksend Mizuhara
|
||||
Oscar
|
||||
Paulius
|
||||
Pedro Burlamaqui Bendahan
|
||||
Peter deHaan
|
||||
Pierre Neter
|
||||
Pin-guang Chen
|
||||
Piotr Drąg
|
||||
Pontoon
|
||||
Quentí
|
||||
Quế Tùng
|
||||
Rachel Tublitz
|
||||
Radu Popescu
|
||||
Rhoslyn Prys
|
||||
RickieES
|
||||
Ricky Rosario
|
||||
Rimas Kudelis
|
||||
Rizky Ariestiyansyah
|
||||
Rob Powell
|
||||
Robert
|
||||
Roberto Alvarado
|
||||
Rodrigo
|
||||
Rodrigo Guerra
|
||||
Rok Žerdin
|
||||
Romi Hardiyanto
|
||||
Rongjian Zhang
|
||||
Ruba
|
||||
Sahithi
|
||||
Sairam Raavi
|
||||
Sander Lepik
|
||||
|
@ -206,127 +98,53 @@ Sara Todaro
|
|||
Sav22999
|
||||
Schieck :)
|
||||
Selim Şumlu
|
||||
Selyan Sliman Amiri
|
||||
Selyan Slimane Amiri
|
||||
Sidak Singh Aulakh
|
||||
Slimane Amiri
|
||||
Slimane Selyan AMIRI
|
||||
Soumya Himanish Mohapatra
|
||||
Staś Małolepszy
|
||||
Suriyaa ✌️️
|
||||
Tema
|
||||
Thomas Dalichow
|
||||
Théo Chevalier
|
||||
Tiago Morais Morgado
|
||||
Tim Visée
|
||||
Tomer Cohen
|
||||
Tomáš Zelina
|
||||
Ton
|
||||
Top
|
||||
Tymur Faradzhev
|
||||
Uccen Marzuq
|
||||
Umegbewe
|
||||
Varghese Thomas
|
||||
Victor Bychek
|
||||
Victor Davila
|
||||
Victor Ibragimov
|
||||
Vimal Raghubir
|
||||
Vitaliy Krutko
|
||||
Weihang Lo
|
||||
Wiktor Furman
|
||||
Wil Clouser
|
||||
YFdyh000
|
||||
Yassine Aït-El-Mouden
|
||||
Yongmin H
|
||||
You-Wen Liang (Mark)
|
||||
Zhenya Tikhonov
|
||||
ZiriSut
|
||||
aaaaalbert
|
||||
abtin
|
||||
ada_okeke60
|
||||
aefgh39622
|
||||
alamanda
|
||||
albertdcastro
|
||||
alex_mayorga
|
||||
ali.malek.71
|
||||
ariestiyansyah
|
||||
avelper
|
||||
biobell2000
|
||||
bulut
|
||||
chilledfrogs
|
||||
clouserw-mozilla-owner
|
||||
dependabot[bot]
|
||||
dgadelha
|
||||
dskmori
|
||||
ehuggett
|
||||
elenatambriz
|
||||
eljuno
|
||||
emily-hou1
|
||||
erdem cobanoglu
|
||||
fcortess
|
||||
gautamkrishnar
|
||||
gmontagu
|
||||
goofy
|
||||
hello
|
||||
hi
|
||||
ivan.pompa
|
||||
jackyzy823
|
||||
jesferman1993
|
||||
jlG
|
||||
jnunezf96
|
||||
johngruen
|
||||
josotrix
|
||||
jspam
|
||||
julen
|
||||
julenx
|
||||
kenrick95
|
||||
kumincir
|
||||
leo.toneff
|
||||
m4hdi.pdroid
|
||||
mail
|
||||
manuel padilla sanchez
|
||||
manxmensch
|
||||
marigalicer
|
||||
marsf
|
||||
merianosnikos
|
||||
minvs1
|
||||
mirzet.omerovic.1992
|
||||
mujeebcpy
|
||||
okyanusoz
|
||||
p.sanroman.bengoetxea
|
||||
passionforlife
|
||||
paul.trevor
|
||||
pyup.io bot
|
||||
ravmn
|
||||
rcmainak
|
||||
reza.habibi2008
|
||||
rgpublic
|
||||
risger
|
||||
robbp
|
||||
ruikunai
|
||||
savemore99.sm
|
||||
sergio
|
||||
shamanchic2011
|
||||
shikhar-scs
|
||||
siparon
|
||||
skystar-p
|
||||
stripTM
|
||||
sugabelly
|
||||
tatalmondmush
|
||||
tiagomoraismorgado
|
||||
timvisee
|
||||
victor.gonzalezro
|
||||
xcffl
|
||||
ybouhamam
|
||||
yoshimitsu002
|
||||
yusup.ramdani
|
||||
zankomhamad
|
||||
Μιχάλης
|
||||
Марко Костић (Marko Kostić)
|
||||
Ратко Вујановић
|
||||
صفا الفليج
|
||||
వీవెన్
|
||||
ജോയ്സ്
|
||||
张无忌
|
||||
新垣结衣松冈茉优长泽雅美门胁麦上野树里石原里美
|
||||
莫非前世那一眼
|
||||
|
|
76
Dockerfile
|
@ -1,73 +1,15 @@
|
|||
##
|
||||
# Send
|
||||
#
|
||||
# License https://gitlab.com/timvisee/send/blob/master/LICENSE
|
||||
##
|
||||
|
||||
# Build project
|
||||
FROM node:16.13-alpine3.13 AS builder
|
||||
|
||||
RUN set -x \
|
||||
# Change node uid/gid
|
||||
&& apk --no-cache add shadow \
|
||||
&& groupmod -g 1001 node \
|
||||
&& usermod -u 1001 -g 1001 node
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
&& addgroup --gid 1000 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 1000 \
|
||||
app
|
||||
|
||||
COPY --chown=app:app . /app
|
||||
FROM node:8-alpine
|
||||
|
||||
RUN apk add --no-cache git
|
||||
RUN addgroup -S -g 10001 app && adduser -S -D -G app -u 10001 app
|
||||
COPY . /app
|
||||
RUN chown -R app /app
|
||||
USER app
|
||||
WORKDIR /app
|
||||
|
||||
RUN set -x \
|
||||
# Build
|
||||
&& PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm ci \
|
||||
&& npm run build
|
||||
|
||||
# Main image
|
||||
FROM node:16.13-alpine3.13
|
||||
|
||||
RUN set -x \
|
||||
# Change node uid/gid
|
||||
&& apk --no-cache add shadow \
|
||||
&& groupmod -g 1001 node \
|
||||
&& usermod -u 1001 -g 1001 node
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
&& addgroup --gid 1000 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 1000 \
|
||||
app
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=app:app package*.json ./
|
||||
COPY --chown=app:app app app
|
||||
COPY --chown=app:app common common
|
||||
COPY --chown=app:app public/locales public/locales
|
||||
COPY --chown=app:app server server
|
||||
COPY --chown=app:app --from=builder /app/dist dist
|
||||
|
||||
RUN npm ci --production && npm cache clean --force
|
||||
RUN mkdir -p /app/.config/configstore
|
||||
RUN ln -s dist/version.json version.json
|
||||
RUN mkdir static
|
||||
RUN npm install --production && npm cache clean --force
|
||||
|
||||
ENV PORT=1443
|
||||
EXPOSE $PORT
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["node", "server/bin/prod.js"]
|
||||
CMD ["npm", "run", "prod"]
|
||||
|
|
110
README.md
|
@ -1,59 +1,9 @@
|
|||
# [](https://gitlab.com/timvisee/send/) Send
|
||||
# Firefox Send
|
||||
|
||||
[![Build status on GitLab CI][gitlab-ci-master-badge]][gitlab-ci-link]
|
||||
[![Latest release][release-badge]][release-link]
|
||||
[![Docker image][docker-image-badge]][docker-image-link]
|
||||
[![Project license][repo-license-badge]](LICENSE)
|
||||
[](https://circleci.com/gh/mozilla/send)
|
||||
[](https://testpilot.firefox.com/experiments/send)
|
||||
|
||||
[docker-image-badge]: https://img.shields.io/badge/docker-latest-blue.svg
|
||||
[docker-image-link]: https://gitlab.com/timvisee/send/container_registry/eyJuYW1lIjoidGltdmlzZWUvc2VuZCIsInRhZ3NfcGF0aCI6Ii90aW12aXNlZS9zZW5kL3JlZ2lzdHJ5L3JlcG9zaXRvcnkvMTQxODUwNC90YWdzP2Zvcm1hdD1qc29uIiwiaWQiOjE0MTg1MDQsImNsZWFudXBfcG9saWN5X3N0YXJ0ZWRfYXQiOm51bGx9
|
||||
[gitlab-ci-link]: https://gitlab.com/timvisee/send/pipelines
|
||||
[gitlab-ci-master-badge]: https://gitlab.com/timvisee/send/badges/master/pipeline.svg
|
||||
[release-badge]: https://img.shields.io/github/v/tag/timvisee/send
|
||||
[release-link]: https://gitlab.com/timvisee/send/-/tags
|
||||
[repo-license-badge]: https://img.shields.io/github/license/timvisee/send.svg
|
||||
|
||||
A fork of Mozilla's [Firefox Send][mozilla-send].
|
||||
Mozilla discontinued Send, this fork is a community effort to keep the project
|
||||
up-to-date and alive.
|
||||
|
||||
- Forked [at][fork-commit] Mozilla's last publicly hosted version
|
||||
- _Mozilla_ & _Firefox_ branding [is][remove-branding-pr] removed so you can legally self-host
|
||||
- Kept compatible with [`ffsend`][ffsend] (CLI for Send)
|
||||
- Dependencies have been updated
|
||||
- Mozilla's [changes][mozilla-patches] since the fork have been selectively [merged][mozilla-patches-pr]
|
||||
- Mozilla's experimental report feature, download tokens, trust warnings and FxA changes are not included
|
||||
|
||||
Find an up-to-date Docker image here: [docs/docker.md](docs/docker.md)
|
||||
|
||||
The original project by Mozilla can be found [here][mozilla-send].
|
||||
The [`mozilla-master`][branch-mozilla-master] branch holds the `master` branch
|
||||
as left by Mozilla.
|
||||
The [`send-v3`][branch-send-v3] branch holds the commit tree of Mozilla's last
|
||||
publicly hosted version, which this fork is based on.
|
||||
The [`send-v4`][branch-send-v4] branch holds the commit tree of Mozilla's last
|
||||
experimental version which was still a work in progress (featuring file
|
||||
reporting, download tokens, trust warnings and FxA changes), this has
|
||||
selectively been merged into this fork.
|
||||
Please consider to [donate][donate] to allow me to keep working on this.
|
||||
|
||||
Thanks [Mozilla][mozilla] for building this amazing tool!
|
||||
|
||||
[branch-mozilla-master]: https://gitlab.com/timvisee/send/-/tree/mozilla-master
|
||||
[branch-send-v3]: https://gitlab.com/timvisee/send/-/tree/send-v3
|
||||
[branch-send-v4]: https://gitlab.com/timvisee/send/-/tree/send-v4
|
||||
[donate]: https://timvisee.com/donate
|
||||
[ffsend]: https://github.com/timvisee/ffsend
|
||||
[fork-commit]: https://gitlab.com/timvisee/send/-/commit/3e9be676413a6e1baaf6a354c180e91899d10bec
|
||||
[mozilla-patches-pr]: https://gitlab.com/timvisee/send/-/merge_requests/3
|
||||
[mozilla-patches]: https://gitlab.com/timvisee/send/-/compare/3e9be676413a6e1baaf6a354c180e91899d10bec...mozilla-master
|
||||
[mozilla-send]: https://github.com/mozilla/send
|
||||
[mozilla]: https://mozilla.org/
|
||||
[remove-branding-pr]: https://gitlab.com/timvisee/send/-/merge_requests/2
|
||||
|
||||
---
|
||||
|
||||
**Docs:** [FAQ](docs/faq.md), [Encryption](docs/encryption.md), [Build](docs/build.md), [Docker](docs/docker.md), [More](docs/)
|
||||
**Docs:** [Docker](docs/docker.md), [Metrics](docs/metrics.md)
|
||||
|
||||
---
|
||||
|
||||
|
@ -66,9 +16,7 @@ Thanks [Mozilla][mozilla] for building this amazing tool!
|
|||
* [Configuration](#configuration)
|
||||
* [Localization](#localization)
|
||||
* [Contributing](#contributing)
|
||||
* [Instances](#instances)
|
||||
* [Deployment](#deployment)
|
||||
* [Clients](#clients)
|
||||
* [Testing](#testing)
|
||||
* [License](#license)
|
||||
|
||||
---
|
||||
|
@ -81,22 +29,22 @@ A file sharing experiment which allows you to send encrypted files to other user
|
|||
|
||||
## Requirements
|
||||
|
||||
- [Node.js 16.x](https://nodejs.org/)
|
||||
- [Node.js 8.2+](https://nodejs.org/)
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
To start an ephemeral development server, run:
|
||||
To start an ephemeral development server run:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Then, browse to http://localhost:8080
|
||||
Then browse to http://localhost:8080
|
||||
|
||||
---
|
||||
|
||||
|
@ -121,45 +69,23 @@ The server is configured with environment variables. See [server/config.js](serv
|
|||
|
||||
## Localization
|
||||
|
||||
See: [docs/localization.md](docs/localization.md)
|
||||
Firefox Send localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/test-pilot-firefox-send/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are always welcome! Feel free to check out the list of "good first issues" (to be implemented).
|
||||
Pull requests are always welcome! Feel free to check out the list of ["good first issues"](https://github.com/mozilla/send/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22).
|
||||
|
||||
---
|
||||
|
||||
## Instances
|
||||
## Testing
|
||||
|
||||
Find a list of public instances here: https://github.com/timvisee/send-instances/
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
See: [docs/deployment.md](docs/deployment.md)
|
||||
|
||||
Docker quickstart: [docs/docker.md](docs/docker.md)
|
||||
|
||||
AWS example using Ubuntu Server `20.04`: [docs/AWS.md](docs/AWS.md)
|
||||
|
||||
---
|
||||
|
||||
## Clients
|
||||
|
||||
- Web: _this repository_
|
||||
- Command-line: [`ffsend`](https://github.com/timvisee/ffsend)
|
||||
- Android: _see [Android](#android) section_
|
||||
- Thunderbird: [FileLink provider for Send](https://addons.thunderbird.net/thunderbird/addon/filelink-provider-for-send/)
|
||||
|
||||
#### 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.
|
||||
| ENVIRONMENT | URL
|
||||
|-------------|-----
|
||||
| Production | <https://send.firefox.com/>
|
||||
| Stage | <https://send.stage.mozaws.net/>
|
||||
| Development | <https://send.dev.mozaws.net/>
|
||||
|
||||
---
|
||||
|
||||
|
@ -167,6 +93,4 @@ located in the `android/app/src/main/assets` directory.
|
|||
|
||||
[Mozilla Public License Version 2.0](LICENSE)
|
||||
|
||||
[qrcode.js](https://github.com/kazuhikoarase/qrcode-generator) licensed under MIT
|
||||
|
||||
---
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
env:
|
||||
browser: true
|
||||
|
||||
parserOptions:
|
||||
sourceType: module
|
||||
|
4
android/.gitignore
vendored
|
@ -1,4 +0,0 @@
|
|||
local.properties
|
||||
.gradle
|
||||
build
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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.
|
|
@ -1,19 +0,0 @@
|
|||
<?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>
|
|
@ -1,99 +0,0 @@
|
|||
import 'intl-pluralrules';
|
||||
import choo from 'choo';
|
||||
import html from 'choo/html';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
import { setApiUrlPrefix, getConstants } from '../app/api';
|
||||
//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';
|
||||
import { setTranslate } from '../app/utils';
|
||||
|
||||
import { delay } from '../app/utils';
|
||||
|
||||
if (navigator.userAgent === 'Send Android') {
|
||||
setApiUrlPrefix('https://send.firefox.com');
|
||||
}
|
||||
|
||||
const app = choo();
|
||||
//app.use(state);
|
||||
app.use(controller);
|
||||
app.use(intents);
|
||||
|
||||
window.finishLogin = async function(accountInfo) {
|
||||
while (!(app.state && app.state.user)) {
|
||||
await delay();
|
||||
}
|
||||
await app.state.user.finishLogin(accountInfo);
|
||||
await app.state.user.syncFileList();
|
||||
app.emitter.emit('replaceState', '/');
|
||||
};
|
||||
|
||||
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 top-0 right-0 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-10 h-screen">
|
||||
${state.cache(Header, 'header').render()} ${main(state, emit)}
|
||||
</body>
|
||||
`;
|
||||
};
|
||||
}
|
||||
(async function start() {
|
||||
const translate = await getTranslator('en-US');
|
||||
setTranslate(translate);
|
||||
const { LIMITS, WEB_UI, DEFAULTS } = await getConstants();
|
||||
app.use(state => {
|
||||
state.LIMITS = LIMITS;
|
||||
state.WEB_UI = WEB_UI;
|
||||
state.DEFAULTS = DEFAULTS;
|
||||
state.translate = translate;
|
||||
state.capabilities = {
|
||||
account: true
|
||||
}; //TODO
|
||||
state.archive = new Archive(
|
||||
[],
|
||||
DEFAULTS.EXPIRE_SECONDS,
|
||||
DEFAULTS.DOWNLOADS
|
||||
);
|
||||
state.storage = storage;
|
||||
state.user = new User(storage, LIMITS);
|
||||
state.sentry = Sentry;
|
||||
});
|
||||
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');
|
||||
})();
|
||||
|
||||
window.app = app;
|
1
android/app/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
/build
|
|
@ -1,41 +0,0 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
defaultConfig {
|
||||
applicationId "org.mozilla.firefoxsend"
|
||||
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:$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
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
#!/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
|
@ -1,21 +0,0 @@
|
|||
# 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
|
|
@ -1,35 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.mozilla.firefoxsend">
|
||||
|
||||
<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="org.mozilla.firefoxsend.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>
|
Before Width: | Height: | Size: 29 KiB |
|
@ -1,226 +0,0 @@
|
|||
package org.mozilla.firefoxsend
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.webkit.*
|
||||
import im.delight.android.webview.AdvancedWebView
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import mozilla.components.service.fxa.Config
|
||||
import mozilla.components.service.fxa.FirefoxAccount
|
||||
import mozilla.components.service.fxa.FxaResult
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class LoggingWebChromeClient : WebChromeClient() {
|
||||
override fun onConsoleMessage(cm: ConsoleMessage): Boolean {
|
||||
Log.d(TAG, String.format("%s @ %d: %s",
|
||||
cm.message(), cm.lineNumber(), cm.sourceId()))
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CONTENT"
|
||||
}
|
||||
}
|
||||
|
||||
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 mToShare: String? = null
|
||||
private var mToCall: String? = null
|
||||
private var mAccount: FirefoxAccount? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
webView.apply {
|
||||
setListener(this@MainActivity, this@MainActivity)
|
||||
addJavascriptInterface(WebAppInterface(this@MainActivity), JS_INTERFACE_NAME)
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
webChromeClient = LoggingWebChromeClient()
|
||||
|
||||
settings.apply {
|
||||
userAgentString = "Send Android"
|
||||
allowUniversalAccessFromFileURLs = true
|
||||
javaScriptEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
val type = intent.type
|
||||
if (Intent.ACTION_SEND == intent.action && type != null) {
|
||||
if (type == "text/plain") {
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
// Log.d(TAG_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.d(TAG_INTENT, "image/ $imageUri")
|
||||
mToShare = "data:text/plain;base64," + Base64.encodeToString(imageUri.path.toByteArray(), 16).trim()
|
||||
}
|
||||
}
|
||||
webView.loadUrl("file:///android_asset/android.html")
|
||||
}
|
||||
|
||||
fun beginOAuthFlow() {
|
||||
Config.release().then { value ->
|
||||
mAccount = FirefoxAccount(value, "20f7931c9054d833", "https://send.firefox.com/fxa/android-redirect.html")
|
||||
mAccount?.beginOAuthFlow(arrayOf("profile", "https://identity.mozilla.com/apps/send"), true)
|
||||
?.then { url ->
|
||||
// Log.d(TAG_CONFIG, "GOT A URL $url")
|
||||
this@MainActivity.runOnUiThread {
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
FxaResult.fromValue(Unit)
|
||||
}
|
||||
// Log.d(TAG_CONFIG, "CREATED FIREFOXACCOUNT")
|
||||
FxaResult.fromValue(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun shareUrl(url: String) {
|
||||
val shareIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, url)
|
||||
}
|
||||
|
||||
val components = arrayOf(ComponentName(applicationContext, MainActivity::class.java))
|
||||
val chooser = Intent.createChooser(shareIntent, "")
|
||||
.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, components)
|
||||
|
||||
startActivity(chooser)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
webView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
webView.onDestroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, intent)
|
||||
webView.onActivityResult(requestCode, resultCode, intent)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (!webView.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
|
||||
webView.loadUrl("file:///android_asset/android.html")
|
||||
|
||||
val uri = Uri.parse(url)
|
||||
uri.getQueryParameter("code")?.let { code ->
|
||||
uri.getQueryParameter("state")?.let { state ->
|
||||
mAccount?.completeOAuthFlow(code, state)?.whenComplete { info ->
|
||||
mAccount?.getProfile(false)?.then { profile ->
|
||||
val profileJsonPayload = JSONObject()
|
||||
.put("accessToken", info.accessToken)
|
||||
.put("keys", info.keys)
|
||||
.put("avatar", profile.avatar)
|
||||
.put("displayName", profile.displayName)
|
||||
.put("email", profile.email)
|
||||
.put("uid", profile.uid).toString()
|
||||
mToCall = "finishLogin($profileJsonPayload)"
|
||||
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.
|
||||
webView.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.
|
||||
webView.loadUrl("file:///android_asset/android.html")
|
||||
}
|
||||
FxaResult.fromValue(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!url.startsWith("file:///android_asset/") && !url.startsWith("https://accounts.firefox.com/")) {
|
||||
// Don't allow loading anything other than the app in our webview
|
||||
// It should be possible to do this with shouldOverrideUrlLoading
|
||||
// but it didn't seem to be working, so this works as a hack.
|
||||
webView.loadUrl("file:///android_asset/android.html")
|
||||
Log.d(TAG_MAIN, "BAD URL " + url)
|
||||
} else {
|
||||
// Log.d(TAG_MAIN, "onPageStarted " + url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(url: String) {
|
||||
// Log.d(TAG_MAIN, "onPageFinished")
|
||||
if (mToShare != null) {
|
||||
// Log.d(TAG_INTENT, mToShare)
|
||||
|
||||
webView.postWebMessage(WebMessage(mToShare), Uri.EMPTY)
|
||||
mToShare = null
|
||||
}
|
||||
if (mToCall != null) {
|
||||
this@MainActivity.runOnUiThread {
|
||||
webView.evaluateJavascript(mToCall) {
|
||||
mToCall = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageError(errorCode: Int, description: String, failingUrl: String) {
|
||||
Log.d(TAG_MAIN, "onPageError($errorCode, $description, $failingUrl)")
|
||||
}
|
||||
|
||||
override fun onDownloadRequested(url: String,
|
||||
suggestedFilename: String,
|
||||
mimeType: String,
|
||||
contentLength: Long,
|
||||
contentDisposition: String,
|
||||
userAgent: String) {
|
||||
// Log.d(TAG_MAIN, "onDownloadRequested")
|
||||
}
|
||||
|
||||
override fun onExternalPageRequest(url: String) {
|
||||
// Log.d(TAG_MAIN, "onExternalPageRequest($url)")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG_MAIN = "MAIN"
|
||||
private const val TAG_INTENT = "INTENT"
|
||||
private const val TAG_CONFIG = "CONFIG"
|
||||
private const val JS_INTERFACE_NAME = "Android"
|
||||
}
|
||||
}
|
|
@ -1,97 +0,0 @@
|
|||
<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>
|
|
@ -1,97 +0,0 @@
|
|||
<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>
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<im.delight.android.webview.AdvancedWebView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/webView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity" />
|
|
@ -1,5 +0,0 @@
|
|||
<?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>
|
|
@ -1,5 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 14 KiB |
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#3F51B5</color>
|
||||
<color name="colorPrimaryDark">#303F9F</color>
|
||||
<color name="colorAccent">#FF4081</color>
|
||||
</resources>
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#220033</color>
|
||||
</resources>
|
|
@ -1,3 +0,0 @@
|
|||
<resources>
|
||||
<string name="app_name">Send</string>
|
||||
</resources>
|
|
@ -1,11 +0,0 @@
|
|||
<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>
|
|
@ -1,27 +0,0 @@
|
|||
// 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.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.21"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
maven { url "https://maven.mozilla.org/maven2" }
|
||||
jcenter()
|
||||
maven { url "https://jitpack.io" }
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
# 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
|
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
|
@ -1,6 +0,0 @@
|
|||
#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
|
@ -1,172 +0,0 @@
|
|||
#!/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
|
@ -1,84 +0,0 @@
|
|||
@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
|
|
@ -1,6 +0,0 @@
|
|||
env:
|
||||
browser: true
|
||||
|
||||
parserOptions:
|
||||
sourceType: module
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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>
|
||||
`;
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
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-50 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 = archiveTile.uploading(state, emit);
|
||||
button = '';
|
||||
} else if (state.archive.numFiles > 0) {
|
||||
content = archiveTile.wip(state, emit);
|
||||
button = '';
|
||||
} else {
|
||||
content =
|
||||
archives.length < 1
|
||||
? intro(state)
|
||||
: list(archives, 'h-full overflow-y-auto w-full', 'mb-3 w-full');
|
||||
}
|
||||
|
||||
return html`
|
||||
<main class="main">
|
||||
${state.modal && modal(state, emit)}
|
||||
<section
|
||||
class="h-full w-full p-6 z-10 overflow-hidden md:flex md:flex-row md:rounded-lg md:shadow-big"
|
||||
>
|
||||
${content}
|
||||
</section>
|
||||
<div class="fixed right-0 bottom-0 z-20">
|
||||
${button}
|
||||
<input
|
||||
id="file-upload"
|
||||
class="hidden"
|
||||
type="file"
|
||||
multiple
|
||||
onchange="${onchange}"
|
||||
onclick="${e => e.stopPropagation()}"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
};
|
|
@ -1,34 +0,0 @@
|
|||
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>
|
||||
`;
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
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>`;
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
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 +0,0 @@
|
|||
include ':app'
|
|
@ -1,20 +0,0 @@
|
|||
/* 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
|
||||
);
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
/* 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}`;
|
||||
},
|
||||
sentry: {
|
||||
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: '/'
|
||||
});
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
/* 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();
|
||||
}
|
||||
|
||||
startAuthFlow() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
367
app/api.js
|
@ -1,58 +1,16 @@
|
|||
import { arrayToB64, b64ToArray, delay } from './utils';
|
||||
import { ECE_RECORD_SIZE } from './ece';
|
||||
import { arrayToB64, b64ToArray } from './utils';
|
||||
|
||||
let fileProtocolWssUrl = null;
|
||||
try {
|
||||
fileProtocolWssUrl = localStorage.getItem('wssURL');
|
||||
} catch (e) {
|
||||
// NOOP
|
||||
}
|
||||
if (!fileProtocolWssUrl) {
|
||||
fileProtocolWssUrl = 'wss://send.firefox.com/api/ws';
|
||||
}
|
||||
|
||||
export class ConnectionError extends Error {
|
||||
constructor(cancelled, duration, size) {
|
||||
super(cancelled ? '0' : 'connection closed');
|
||||
this.cancelled = cancelled;
|
||||
this.duration = duration;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
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['Authorization'] = `Bearer ${bearerToken}`;
|
||||
}
|
||||
function post(obj) {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: new Headers(h),
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(obj)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNonce(header) {
|
||||
function parseNonce(header) {
|
||||
header = header || '';
|
||||
return header.split(' ')[1];
|
||||
}
|
||||
|
@ -61,10 +19,7 @@ async function fetchWithAuth(url, params, keychain) {
|
|||
const result = {};
|
||||
params = params || {};
|
||||
const h = await keychain.authHeader();
|
||||
params.headers = new Headers({
|
||||
Authorization: h,
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
params.headers = new Headers({ Authorization: h });
|
||||
const response = await fetch(url, params);
|
||||
result.response = response;
|
||||
result.ok = response.ok;
|
||||
|
@ -83,44 +38,33 @@ async function fetchWithAuthAndRetry(url, params, keychain) {
|
|||
}
|
||||
|
||||
export async function del(id, owner_token) {
|
||||
const response = await fetch(
|
||||
getApiUrl(`/api/delete/${id}`),
|
||||
post({ owner_token })
|
||||
);
|
||||
const response = await fetch(`/api/delete/${id}`, post({ owner_token }));
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function setParams(id, owner_token, bearerToken, params) {
|
||||
export async function setParams(id, owner_token, params) {
|
||||
const response = await fetch(
|
||||
getApiUrl(`/api/params/${id}`),
|
||||
post(
|
||||
{
|
||||
owner_token,
|
||||
dlimit: params.dlimit
|
||||
},
|
||||
bearerToken
|
||||
)
|
||||
`/api/params/${id}`,
|
||||
post({
|
||||
owner_token,
|
||||
dlimit: params.dlimit
|
||||
})
|
||||
);
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function fileInfo(id, owner_token) {
|
||||
const response = await fetch(
|
||||
getApiUrl(`/api/info/${id}`),
|
||||
post({ owner_token })
|
||||
);
|
||||
|
||||
const response = await fetch(`/api/info/${id}`, post({ owner_token }));
|
||||
if (response.ok) {
|
||||
const obj = await response.json();
|
||||
return obj;
|
||||
}
|
||||
|
||||
throw new Error(response.status);
|
||||
}
|
||||
|
||||
export async function metadata(id, keychain) {
|
||||
const result = await fetchWithAuthAndRetry(
|
||||
getApiUrl(`/api/metadata/${id}`),
|
||||
`/api/metadata/${id}`,
|
||||
{ method: 'GET' },
|
||||
keychain
|
||||
);
|
||||
|
@ -128,12 +72,11 @@ export async function metadata(id, keychain) {
|
|||
const data = await result.response.json();
|
||||
const meta = await keychain.decryptMetadata(b64ToArray(data.metadata));
|
||||
return {
|
||||
size: meta.size,
|
||||
size: data.size,
|
||||
ttl: data.ttl,
|
||||
iv: meta.iv,
|
||||
name: meta.name,
|
||||
type: meta.type,
|
||||
manifest: meta.manifest
|
||||
type: meta.type
|
||||
};
|
||||
}
|
||||
throw new Error(result.response.status);
|
||||
|
@ -142,212 +85,64 @@ export async function metadata(id, keychain) {
|
|||
export async function setPassword(id, owner_token, keychain) {
|
||||
const auth = await keychain.authKeyB64();
|
||||
const response = await fetch(
|
||||
getApiUrl(`/api/password/${id}`),
|
||||
`/api/password/${id}`,
|
||||
post({ owner_token, auth })
|
||||
);
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
function asyncInitWebSocket(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(server);
|
||||
ws.addEventListener('open', () => resolve(ws), { once: true });
|
||||
} catch (e) {
|
||||
reject(new ConnectionError(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function listenForResponse(ws, canceller) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function handleClose(event) {
|
||||
// a 'close' event before a 'message' event means the request failed
|
||||
ws.removeEventListener('message', handleMessage);
|
||||
reject(new ConnectionError(canceller.cancelled));
|
||||
}
|
||||
function handleMessage(msg) {
|
||||
ws.removeEventListener('close', handleClose);
|
||||
try {
|
||||
const response = JSON.parse(msg.data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
ws.addEventListener('message', handleMessage, { once: true });
|
||||
ws.addEventListener('close', handleClose, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function upload(
|
||||
stream,
|
||||
metadata,
|
||||
verifierB64,
|
||||
timeLimit,
|
||||
dlimit,
|
||||
bearerToken,
|
||||
onprogress,
|
||||
canceller
|
||||
) {
|
||||
let size = 0;
|
||||
const start = Date.now();
|
||||
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();
|
||||
while (!state.done) {
|
||||
if (canceller.cancelled) {
|
||||
ws.close();
|
||||
}
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
break;
|
||||
}
|
||||
const buf = state.value;
|
||||
ws.send(buf);
|
||||
onprogress(size);
|
||||
size += buf.length;
|
||||
state = await reader.read();
|
||||
while (
|
||||
ws.bufferedAmount > ECE_RECORD_SIZE * 2 &&
|
||||
ws.readyState === WebSocket.OPEN &&
|
||||
!canceller.cancelled
|
||||
) {
|
||||
await delay();
|
||||
}
|
||||
}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new Uint8Array([0])); //EOF
|
||||
}
|
||||
|
||||
await completedResponse;
|
||||
uploadInfo.duration = Date.now() - start;
|
||||
return uploadInfo;
|
||||
} catch (e) {
|
||||
e.size = size;
|
||||
e.duration = Date.now() - start;
|
||||
throw e;
|
||||
} finally {
|
||||
if (![WebSocket.CLOSED, WebSocket.CLOSING].includes(ws.readyState)) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadWs(
|
||||
export function uploadFile(
|
||||
encrypted,
|
||||
metadata,
|
||||
verifierB64,
|
||||
timeLimit,
|
||||
dlimit,
|
||||
bearerToken,
|
||||
keychain,
|
||||
onprogress
|
||||
) {
|
||||
const canceller = { cancelled: false };
|
||||
|
||||
return {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const upload = {
|
||||
cancel: function() {
|
||||
canceller.cancelled = true;
|
||||
xhr.abort();
|
||||
},
|
||||
|
||||
result: upload(
|
||||
encrypted,
|
||||
metadata,
|
||||
verifierB64,
|
||||
timeLimit,
|
||||
dlimit,
|
||||
bearerToken,
|
||||
onprogress,
|
||||
canceller
|
||||
)
|
||||
result: new Promise(function(resolve, reject) {
|
||||
xhr.addEventListener('loadend', function() {
|
||||
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
|
||||
if (authHeader) {
|
||||
keychain.nonce = parseNonce(authHeader);
|
||||
}
|
||||
if (xhr.status === 200) {
|
||||
const responseObj = JSON.parse(xhr.responseText);
|
||||
return resolve({
|
||||
url: responseObj.url,
|
||||
id: responseObj.id,
|
||||
ownerToken: responseObj.owner
|
||||
});
|
||||
}
|
||||
reject(new Error(xhr.status));
|
||||
});
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
////////////////////////
|
||||
|
||||
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 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]);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
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 tryDownloadStream(id, keychain, signal, tries = 2) {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
//////////////////
|
||||
|
||||
async function download(id, keychain, onprogress, canceller) {
|
||||
const auth = await keychain.authHeader();
|
||||
function download(id, keychain, onprogress, canceller) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
canceller.oncancel = function() {
|
||||
xhr.abort();
|
||||
};
|
||||
return new Promise(function(resolve, reject) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
xhr.addEventListener('loadend', function() {
|
||||
canceller.oncancel = function() {};
|
||||
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
|
||||
|
@ -359,23 +154,26 @@ async function download(id, keychain, onprogress, canceller) {
|
|||
}
|
||||
|
||||
const blob = new Blob([xhr.response]);
|
||||
resolve(blob);
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
fileReader.onload = function() {
|
||||
resolve(this.result);
|
||||
};
|
||||
});
|
||||
|
||||
xhr.addEventListener('progress', function(event) {
|
||||
if (event.target.status === 200) {
|
||||
onprogress(event.loaded);
|
||||
if (event.lengthComputable && event.target.status === 200) {
|
||||
onprogress([event.loaded, event.total]);
|
||||
}
|
||||
});
|
||||
xhr.open('get', getApiUrl(`/api/download/blob/${id}`));
|
||||
const auth = await keychain.authHeader();
|
||||
xhr.open('get', `/api/download/${id}`);
|
||||
xhr.setRequestHeader('Authorization', auth);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.send();
|
||||
onprogress(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function tryDownload(id, keychain, onprogress, canceller, tries = 2) {
|
||||
async function tryDownload(id, keychain, onprogress, canceller, tries = 1) {
|
||||
try {
|
||||
const result = await download(id, keychain, onprogress, canceller);
|
||||
return result;
|
||||
|
@ -396,37 +194,6 @@ export function downloadFile(id, keychain, onprogress) {
|
|||
}
|
||||
return {
|
||||
cancel,
|
||||
result: tryDownload(id, keychain, onprogress, canceller)
|
||||
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 async function getConstants() {
|
||||
const response = await fetch(getApiUrl('/config'));
|
||||
|
||||
if (response.ok) {
|
||||
const obj = await response.json();
|
||||
return obj;
|
||||
}
|
||||
|
||||
throw new Error(response.status);
|
||||
}
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
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, defaultDownloadLimit = 1) {
|
||||
this.files = Array.from(files);
|
||||
this.defaultTimeLimit = defaultTimeLimit;
|
||||
this.defaultDownloadLimit = defaultDownloadLimit;
|
||||
this.timeLimit = defaultTimeLimit;
|
||||
this.dlimit = defaultDownloadLimit;
|
||||
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 = this.defaultDownloadLimit;
|
||||
this.timeLimit = this.defaultTimeLimit;
|
||||
this.password = null;
|
||||
}
|
||||
}
|
267
app/base.css
Normal file
|
@ -0,0 +1,267 @@
|
|||
: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 */
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,116 +0,0 @@
|
|||
/* global AUTH_CONFIG */
|
||||
import { browserName, locale } 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 browser = browserName();
|
||||
const isMobile = /mobi|android/i.test(navigator.userAgent);
|
||||
const serviceWorker = 'serviceWorker' in navigator && browser !== '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;
|
||||
}
|
||||
const share =
|
||||
isMobile &&
|
||||
typeof navigator.share === 'function' &&
|
||||
locale().startsWith('en'); // en until strings merge
|
||||
|
||||
const standalone =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
navigator.standalone;
|
||||
|
||||
const mobileFirefox = browser === 'firefox' && isMobile;
|
||||
|
||||
return {
|
||||
account,
|
||||
crypto,
|
||||
serviceWorker,
|
||||
streamUpload: nativeStreams || polyStreams,
|
||||
streamDownload:
|
||||
nativeStreams && serviceWorker && browser !== 'safari' && !mobileFirefox,
|
||||
multifile: nativeStreams || polyStreams,
|
||||
share,
|
||||
standalone
|
||||
};
|
||||
}
|
|
@ -1,309 +0,0 @@
|
|||
import FileReceiver from './fileReceiver';
|
||||
import FileSender from './fileSender';
|
||||
import copyDialog from './ui/copyDialog';
|
||||
import faviconProgressbar from './ui/faviconProgressbar';
|
||||
import okDialog from './ui/okDialog';
|
||||
import shareDialog from './ui/shareDialog';
|
||||
import signupDialog from './ui/signupDialog';
|
||||
import surveyDialog from './ui/surveyDialog';
|
||||
import { bytes, locale } from './utils';
|
||||
import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
|
||||
|
||||
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));
|
||||
}
|
||||
faviconProgressbar.updateFavicon(state.transfer.progressRatio);
|
||||
render();
|
||||
}
|
||||
|
||||
emitter.on('DOMContentLoaded', () => {
|
||||
document.addEventListener('blur', () => (updateTitle = true));
|
||||
document.addEventListener('focus', () => {
|
||||
updateTitle = false;
|
||||
emitter.emit('DOMTitleChange', 'Send');
|
||||
faviconProgressbar.updateFavicon(0);
|
||||
});
|
||||
checkFiles();
|
||||
});
|
||||
|
||||
emitter.on('render', () => {
|
||||
lastRender = Date.now();
|
||||
});
|
||||
|
||||
emitter.on('login', email => {
|
||||
state.user.login(email);
|
||||
});
|
||||
|
||||
emitter.on('logout', async () => {
|
||||
await state.user.logout();
|
||||
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 {
|
||||
state.storage.remove(ownedFile.id);
|
||||
await ownedFile.del();
|
||||
} catch (e) {
|
||||
state.sentry.captureException(e);
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('cancel', () => {
|
||||
state.transfer.cancel();
|
||||
faviconProgressbar.updateFavicon(0);
|
||||
});
|
||||
|
||||
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) {
|
||||
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();
|
||||
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);
|
||||
try {
|
||||
const ownedFile = await sender.upload(archive, state.user.bearerToken);
|
||||
state.storage.totalUploads += 1;
|
||||
faviconProgressbar.updateFavicon(0);
|
||||
|
||||
state.storage.addFile(ownedFile);
|
||||
// TODO integrate password into /upload request
|
||||
if (archive.password) {
|
||||
emitter.emit('password', {
|
||||
password: archive.password,
|
||||
file: ownedFile
|
||||
});
|
||||
}
|
||||
state.modal = state.capabilities.share
|
||||
? shareDialog(ownedFile.name, ownedFile.url)
|
||||
: copyDialog(ownedFile.name, ownedFile.url);
|
||||
} catch (err) {
|
||||
if (err.message === '0') {
|
||||
//cancelled. do nothing
|
||||
render();
|
||||
} else if (err.message === '401') {
|
||||
const refreshed = await state.user.refresh();
|
||||
if (refreshed) {
|
||||
return emitter.emit('upload');
|
||||
}
|
||||
emitter.emit('pushState', '/error');
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
state.sentry.withScope(scope => {
|
||||
scope.setExtra('duration', err.duration);
|
||||
scope.setExtra('size', err.size);
|
||||
state.sentry.captureException(err);
|
||||
});
|
||||
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');
|
||||
}
|
||||
} else {
|
||||
console.error(e);
|
||||
return emitter.emit('pushState', '/error');
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('download', async () => {
|
||||
state.transfer.on('progress', updateProgress);
|
||||
state.transfer.on('decrypting', render);
|
||||
state.transfer.on('complete', render);
|
||||
const links = openLinksInNewTab();
|
||||
try {
|
||||
const dl = state.transfer.download({
|
||||
stream: state.capabilities.streamDownload
|
||||
});
|
||||
render();
|
||||
await dl;
|
||||
state.storage.totalDownloads += 1;
|
||||
faviconProgressbar.updateFavicon(0);
|
||||
} 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.sentry.withScope(scope => {
|
||||
scope.setExtra('duration', err.duration);
|
||||
scope.setExtra('size', err.size);
|
||||
scope.setExtra('progress', err.progress);
|
||||
state.sentry.captureException(err);
|
||||
});
|
||||
}
|
||||
emitter.emit('pushState', location);
|
||||
}
|
||||
} finally {
|
||||
openLinksInNewTab(links, false);
|
||||
}
|
||||
});
|
||||
|
||||
emitter.on('copy', ({ url }) => {
|
||||
copyToClipboard(url);
|
||||
});
|
||||
|
||||
emitter.on('closeModal', () => {
|
||||
if (
|
||||
state.PREFS.surveyUrl &&
|
||||
['copy', 'share'].includes(state.modal.type) &&
|
||||
locale().startsWith('en') &&
|
||||
(state.storage.totalUploads > 1 || state.storage.totalDownloads > 0) &&
|
||||
!state.user.surveyed
|
||||
) {
|
||||
state.user.surveyed = true;
|
||||
state.modal = surveyDialog();
|
||||
} else {
|
||||
state.modal = null;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
/* global MAXFILESIZE */
|
||||
const { bytes } = require('./utils');
|
||||
|
||||
export default function(state, emitter) {
|
||||
emitter.on('DOMContentLoaded', () => {
|
||||
document.body.addEventListener('dragover', event => {
|
||||
|
@ -6,16 +9,29 @@ export default function(state, emitter) {
|
|||
}
|
||||
});
|
||||
document.body.addEventListener('drop', event => {
|
||||
if (
|
||||
state.route === '/' &&
|
||||
!state.uploading &&
|
||||
event.dataTransfer &&
|
||||
event.dataTransfer.files
|
||||
) {
|
||||
if (state.route === '/' && !state.uploading) {
|
||||
event.preventDefault();
|
||||
emitter.emit('addFiles', {
|
||||
files: Array.from(event.dataTransfer.files)
|
||||
});
|
||||
document
|
||||
.querySelector('.uploadArea')
|
||||
.classList.remove('uploadArea--dragging');
|
||||
const target = event.dataTransfer;
|
||||
if (target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (target.files.length > 1) {
|
||||
return alert(state.translate('uploadPageMultipleFilesAlert'));
|
||||
}
|
||||
const file = target.files[0];
|
||||
if (file.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (file.size > MAXFILESIZE) {
|
||||
window.alert(
|
||||
state.translate('fileTooBig', { size: bytes(MAXFILESIZE) })
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitter.emit('upload', { file, type: 'drop' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
310
app/ece.js
|
@ -1,310 +0,0 @@
|
|||
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));
|
||||
}
|
|
@ -1,19 +1,38 @@
|
|||
import hash from 'string-hash';
|
||||
import Account from './ui/account';
|
||||
|
||||
const experiments = {
|
||||
signin_button_color: {
|
||||
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 true;
|
||||
return (
|
||||
!/firefox|fxios/i.test(navigator.userAgent) &&
|
||||
document.querySelector('html').lang === 'en-US'
|
||||
);
|
||||
},
|
||||
variant: function() {
|
||||
return ['white-primary', 'primary', 'white-violet', 'violet'][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
variant: function(state) {
|
||||
const n = this.luckyNumber(state);
|
||||
if (n < 0.33) {
|
||||
return 0;
|
||||
}
|
||||
return n < 0.66 ? 1 : 2;
|
||||
},
|
||||
run: function(variant, state) {
|
||||
const account = state.cache(Account, 'account');
|
||||
account.buttonClass = variant;
|
||||
luckyNumber: function(state) {
|
||||
return luckyNumber(
|
||||
`${this.id}:${state.storage.get('testpilot_ga__cid')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -41,12 +60,23 @@ export default function initialize(state, emitter) {
|
|||
xp.run(+state.query.v, state, emitter);
|
||||
}
|
||||
});
|
||||
const enrolled = state.storage.enrolled;
|
||||
// single experiment per session for now
|
||||
const id = Object.keys(enrolled)[0];
|
||||
if (Object.keys(experiments).includes(id)) {
|
||||
experiments[id].run(enrolled[id], state, emitter);
|
||||
|
||||
if (!state.storage.get('testpilot_ga__cid')) {
|
||||
// first ever visit. check again after cid is assigned.
|
||||
emitter.on('DOMContentLoaded', () => {
|
||||
checkExperiments(state, emitter);
|
||||
});
|
||||
} else {
|
||||
checkExperiments(state, emitter);
|
||||
const enrolled = state.storage.enrolled.filter(([id, variant]) => {
|
||||
const xp = experiments[id];
|
||||
if (xp) {
|
||||
xp.run(variant, state, emitter);
|
||||
}
|
||||
return !!xp;
|
||||
});
|
||||
// single experiment per session for now
|
||||
if (enrolled.length === 0) {
|
||||
checkExperiments(state, emitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
225
app/fileManager.js
Normal file
|
@ -0,0 +1,225 @@
|
|||
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);
|
||||
|
||||
document.getElementById('cancel-upload').hidden = 'hidden';
|
||||
await delay(1000);
|
||||
await fadeOut('.page');
|
||||
openLinksInNewTab(links, false);
|
||||
emitter.emit('pushState', `/share/${ownedFile.id}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err.message === '0') {
|
||||
//cancelled. do nothing
|
||||
metrics.cancelledUpload({ size, type });
|
||||
return render();
|
||||
}
|
||||
state.raven.captureException(err);
|
||||
metrics.stoppedUpload({ size, type, err });
|
||||
emitter.emit('pushState', '/error');
|
||||
} finally {
|
||||
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) {
|
||||
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();
|
||||
return render();
|
||||
}
|
||||
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);
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
import Nanobus from 'nanobus';
|
||||
import Keychain from './keychain';
|
||||
import { delay, bytes, streamToArrayBuffer } from './utils';
|
||||
import { downloadFile, metadata, getApiUrl, reportLink } from './api';
|
||||
import { blobStream } from './streams';
|
||||
import Zip from './zip';
|
||||
import { bytes } from './utils';
|
||||
import { metadata, downloadFile } from './api';
|
||||
|
||||
export default class FileReceiver extends Nanobus {
|
||||
constructor(fileInfo) {
|
||||
|
@ -45,43 +43,21 @@ export default class FileReceiver extends Nanobus {
|
|||
|
||||
async getMetadata() {
|
||||
const meta = await metadata(this.fileInfo.id, this.keychain);
|
||||
this.keychain.setIV(meta.iv);
|
||||
this.fileInfo.name = meta.name;
|
||||
this.fileInfo.type = meta.type;
|
||||
this.fileInfo.iv = meta.iv;
|
||||
this.fileInfo.size = +meta.size;
|
||||
this.fileInfo.manifest = meta.manifest;
|
||||
this.fileInfo.size = meta.size;
|
||||
this.state = 'ready';
|
||||
}
|
||||
|
||||
async reportLink(reason) {
|
||||
await reportLink(this.fileInfo.id, this.keychain, reason);
|
||||
}
|
||||
|
||||
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) {
|
||||
async download(noSave = false) {
|
||||
this.state = 'downloading';
|
||||
this.downloadRequest = await downloadFile(
|
||||
this.fileInfo.id,
|
||||
this.keychain,
|
||||
p => {
|
||||
this.progress = [p, this.fileInfo.size];
|
||||
this.progress = p;
|
||||
this.emit('progress');
|
||||
}
|
||||
);
|
||||
|
@ -91,14 +67,7 @@ export default class FileReceiver extends Nanobus {
|
|||
this.msg = 'decryptingFile';
|
||||
this.state = 'decrypting';
|
||||
this.emit('decrypting');
|
||||
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);
|
||||
const plaintext = await this.keychain.decryptFile(ciphertext);
|
||||
if (!noSave) {
|
||||
await saveFile({
|
||||
plaintext,
|
||||
|
@ -107,113 +76,12 @@ export default class FileReceiver extends Nanobus {
|
|||
});
|
||||
}
|
||||
this.msg = 'downloadFinish';
|
||||
this.emit('complete');
|
||||
this.state = 'complete';
|
||||
} catch (e) {
|
||||
this.downloadRequest = null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadStream(noSave = false) {
|
||||
const start = Date.now();
|
||||
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}${downloadPath}`;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}
|
||||
|
||||
let prog = 0;
|
||||
let hangs = 0;
|
||||
while (prog < this.fileInfo.size) {
|
||||
const msg = await this.sendMessageToSw({
|
||||
request: 'progress',
|
||||
id: this.fileInfo.id
|
||||
});
|
||||
if (msg.progress === prog) {
|
||||
hangs++;
|
||||
} else {
|
||||
hangs = 0;
|
||||
}
|
||||
if (hangs > 30) {
|
||||
// TODO: On Chrome we don't get a cancel
|
||||
// signal so one is indistinguishable from
|
||||
// a hang. We may be able to detect
|
||||
// which end is hung in the service worker
|
||||
// to improve on this.
|
||||
const e = new Error('hung download');
|
||||
e.duration = Date.now() - start;
|
||||
e.size = this.fileInfo.size;
|
||||
e.progress = prog;
|
||||
throw e;
|
||||
}
|
||||
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) {
|
||||
|
@ -224,6 +92,24 @@ async function saveFile(file) {
|
|||
if (navigator.msSaveBlob) {
|
||||
navigator.msSaveBlob(blob, file.name);
|
||||
return resolve();
|
||||
} else if (/iPhone|fxios/i.test(navigator.userAgent)) {
|
||||
// This method is much slower but createObjectURL
|
||||
// is buggy on iOS
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('loadend', function() {
|
||||
if (reader.error) {
|
||||
return reject(reader.error);
|
||||
}
|
||||
if (reader.result) {
|
||||
const a = document.createElement('a');
|
||||
a.href = reader.result;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
} else {
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
/* global EXPIRE_SECONDS */
|
||||
import Nanobus from 'nanobus';
|
||||
import OwnedFile from './ownedFile';
|
||||
import Keychain from './keychain';
|
||||
import { arrayToB64, bytes } from './utils';
|
||||
import { uploadWs } from './api';
|
||||
import { encryptedSize } from './utils';
|
||||
import { uploadFile } from './api';
|
||||
|
||||
export default class FileSender extends Nanobus {
|
||||
constructor() {
|
||||
constructor(file) {
|
||||
super('FileSender');
|
||||
this.file = file;
|
||||
this.keychain = new Keychain();
|
||||
this.reset();
|
||||
}
|
||||
|
@ -17,9 +18,7 @@ export default class FileSender extends Nanobus {
|
|||
}
|
||||
|
||||
get progressIndefinite() {
|
||||
return (
|
||||
['fileSizeProgress', 'notifyUploadEncryptDone'].indexOf(this.msg) === -1
|
||||
);
|
||||
return ['fileSizeProgress', 'notifyUploadDone'].indexOf(this.msg) === -1;
|
||||
}
|
||||
|
||||
get sizes() {
|
||||
|
@ -43,59 +42,66 @@ export default class FileSender extends Nanobus {
|
|||
}
|
||||
}
|
||||
|
||||
async upload(archive, bearerToken) {
|
||||
readFile() {
|
||||
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 plaintext = await this.readFile();
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
}
|
||||
this.msg = 'encryptingFile';
|
||||
this.emit('encrypting');
|
||||
const totalSize = encryptedSize(archive.size);
|
||||
const encStream = await this.keychain.encryptStream(archive.stream);
|
||||
const metadata = await this.keychain.encryptMetadata(archive);
|
||||
const encrypted = await this.keychain.encryptFile(plaintext);
|
||||
const metadata = await this.keychain.encryptMetadata(this.file);
|
||||
const authKeyB64 = await this.keychain.authKeyB64();
|
||||
|
||||
this.uploadRequest = uploadWs(
|
||||
encStream,
|
||||
metadata,
|
||||
authKeyB64,
|
||||
archive.timeLimit,
|
||||
archive.dlimit,
|
||||
bearerToken,
|
||||
p => {
|
||||
this.progress = [p, totalSize];
|
||||
this.emit('progress');
|
||||
}
|
||||
);
|
||||
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
}
|
||||
|
||||
this.uploadRequest = uploadFile(
|
||||
encrypted,
|
||||
metadata,
|
||||
authKeyB64,
|
||||
this.keychain,
|
||||
p => {
|
||||
this.progress = p;
|
||||
this.emit('progress', p);
|
||||
}
|
||||
);
|
||||
this.msg = 'fileSizeProgress';
|
||||
this.emit('progress'); // HACK to kick MS Edge
|
||||
try {
|
||||
const result = await this.uploadRequest.result;
|
||||
this.msg = 'notifyUploadEncryptDone';
|
||||
const time = Date.now() - start;
|
||||
this.msg = 'notifyUploadDone';
|
||||
this.uploadRequest = null;
|
||||
this.progress = [1, 1];
|
||||
const secretKey = arrayToB64(this.keychain.rawSecret);
|
||||
const ownedFile = new OwnedFile({
|
||||
id: result.id,
|
||||
url: `${result.url}#${secretKey}`,
|
||||
name: archive.name,
|
||||
size: archive.size,
|
||||
manifest: archive.manifest,
|
||||
time: result.duration,
|
||||
speed: archive.size / (result.duration / 1000),
|
||||
name: this.file.name,
|
||||
size: this.file.size,
|
||||
time: time,
|
||||
speed: this.file.size / (time / 1000),
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + archive.timeLimit * 1000,
|
||||
expiresAt: Date.now() + EXPIRE_SECONDS * 1000,
|
||||
secretKey: secretKey,
|
||||
nonce: this.keychain.nonce,
|
||||
ownerToken: result.ownerToken,
|
||||
dlimit: archive.dlimit,
|
||||
timeLimit: archive.timeLimit
|
||||
ownerToken: result.ownerToken
|
||||
});
|
||||
|
||||
return ownedFile;
|
||||
} catch (e) {
|
||||
this.msg = 'errorPageHeader';
|
||||
|
|
181
app/fxa.js
|
@ -1,181 +0,0 @@
|
|||
/* 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);
|
||||
}
|
|
@ -1,25 +1,47 @@
|
|||
import { arrayToB64, b64ToArray } from './utils';
|
||||
import { decryptStream, encryptStream } from './ece.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export default class Keychain {
|
||||
constructor(secretKeyB64, nonce) {
|
||||
constructor(secretKeyB64, nonce, ivB64) {
|
||||
this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ==';
|
||||
if (ivB64) {
|
||||
this.iv = b64ToArray(ivB64);
|
||||
} else {
|
||||
this.iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
}
|
||||
if (secretKeyB64) {
|
||||
this.rawSecret = b64ToArray(secretKeyB64);
|
||||
} else {
|
||||
this.rawSecret = crypto.getRandomValues(new Uint8Array(16));
|
||||
this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
}
|
||||
this.secretKeyPromise = crypto.subtle.importKey(
|
||||
this.secretKeyPromise = window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
this.rawSecret,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) {
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
salt: new Uint8Array(),
|
||||
info: encoder.encode('encryption'),
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
secretKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 128
|
||||
},
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
});
|
||||
this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) {
|
||||
return crypto.subtle.deriveKey(
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
salt: new Uint8Array(),
|
||||
|
@ -36,7 +58,7 @@ export default class Keychain {
|
|||
);
|
||||
});
|
||||
this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) {
|
||||
return crypto.subtle.deriveKey(
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
salt: new Uint8Array(),
|
||||
|
@ -64,13 +86,17 @@ export default class Keychain {
|
|||
}
|
||||
}
|
||||
|
||||
setIV(ivB64) {
|
||||
this.iv = b64ToArray(ivB64);
|
||||
}
|
||||
|
||||
setPassword(password, shareUrl) {
|
||||
this.authKeyPromise = crypto.subtle
|
||||
this.authKeyPromise = window.crypto.subtle
|
||||
.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [
|
||||
'deriveKey'
|
||||
])
|
||||
.then(passwordKey =>
|
||||
crypto.subtle.deriveKey(
|
||||
window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: encoder.encode(shareUrl),
|
||||
|
@ -89,7 +115,7 @@ export default class Keychain {
|
|||
}
|
||||
|
||||
setAuthKey(authKeyB64) {
|
||||
this.authKeyPromise = crypto.subtle.importKey(
|
||||
this.authKeyPromise = window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
b64ToArray(authKeyB64),
|
||||
{
|
||||
|
@ -103,13 +129,13 @@ export default class Keychain {
|
|||
|
||||
async authKeyB64() {
|
||||
const authKey = await this.authKeyPromise;
|
||||
const rawAuth = await crypto.subtle.exportKey('raw', authKey);
|
||||
const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
|
||||
return arrayToB64(new Uint8Array(rawAuth));
|
||||
}
|
||||
|
||||
async authHeader() {
|
||||
const authKey = await this.authKeyPromise;
|
||||
const sig = await crypto.subtle.sign(
|
||||
const sig = await window.crypto.subtle.sign(
|
||||
{
|
||||
name: 'HMAC'
|
||||
},
|
||||
|
@ -119,9 +145,23 @@ export default class Keychain {
|
|||
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) {
|
||||
const metaKey = await this.metaKeyPromise;
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: new Uint8Array(12),
|
||||
|
@ -130,27 +170,32 @@ export default class Keychain {
|
|||
metaKey,
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
iv: arrayToB64(this.iv),
|
||||
name: metadata.name,
|
||||
size: metadata.size,
|
||||
type: metadata.type || 'application/octet-stream',
|
||||
manifest: metadata.manifest || {}
|
||||
type: metadata.type || 'application/octet-stream'
|
||||
})
|
||||
)
|
||||
);
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
encryptStream(plainStream) {
|
||||
return encryptStream(plainStream, this.rawSecret);
|
||||
}
|
||||
|
||||
decryptStream(cryptotext) {
|
||||
return decryptStream(cryptotext, this.rawSecret);
|
||||
async decryptFile(ciphertext) {
|
||||
const encryptKey = await this.encryptKeyPromise;
|
||||
const plaintext = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: this.iv,
|
||||
tagLength: 128
|
||||
},
|
||||
encryptKey,
|
||||
ciphertext
|
||||
);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
async decryptMetadata(ciphertext) {
|
||||
const metaKey = await this.metaKeyPromise;
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
const plaintext = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: new Uint8Array(12),
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import { FluentBundle, FluentResource } from '@fluent/bundle';
|
||||
|
||||
function makeBundle(locale, ftl) {
|
||||
const bundle = new FluentBundle(locale, { useIsolating: false });
|
||||
bundle.addResource(new FluentResource(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.formatPattern(bundle.getMessage(id).value, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
428
app/main.css
|
@ -1,412 +1,16 @@
|
|||
@tailwind base;
|
||||
|
||||
html {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
|
||||
:not(input) {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--violet-gradient: linear-gradient(
|
||||
-180deg,
|
||||
rgb(144 89 255 / 80%) 0%,
|
||||
rgb(144 89 255 / 40%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus {
|
||||
outline: 1px dotted grey;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image: url('../assets/bg.svg');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply bg-primary;
|
||||
@apply text-white;
|
||||
@apply cursor-pointer;
|
||||
@apply py-4;
|
||||
@apply px-6;
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@apply bg-primary_accent;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
@apply bg-primary_accent;
|
||||
}
|
||||
|
||||
.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-10; */
|
||||
@apply border-default;
|
||||
@apply rounded-sm;
|
||||
|
||||
content: '';
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.checkbox > label:hover::before {
|
||||
@apply border-primary;
|
||||
}
|
||||
|
||||
.checkbox > input:focus + label::before {
|
||||
@apply border-primary;
|
||||
}
|
||||
|
||||
.checkbox > input:checked + label::before {
|
||||
@apply bg-primary;
|
||||
@apply border-primary;
|
||||
|
||||
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-primary;
|
||||
@apply border-primary;
|
||||
|
||||
background-image: url('../assets/lock.svg');
|
||||
background-position: center;
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
details {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
details > summary::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 a: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;
|
||||
}
|
||||
|
||||
.link-primary {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.link-primary:hover {
|
||||
@apply text-primary_accent;
|
||||
}
|
||||
|
||||
.link-primary:focus {
|
||||
@apply text-primary_accent;
|
||||
}
|
||||
|
||||
.main-header img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.text-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.d-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d-inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
position: relative;
|
||||
max-width: 64rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main > section {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
#password-msg::after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
progress {
|
||||
@apply bg-grey-30;
|
||||
@apply rounded-sm;
|
||||
@apply w-full;
|
||||
@apply h-1;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-bar {
|
||||
@apply bg-grey-30;
|
||||
@apply rounded-sm;
|
||||
@apply w-full;
|
||||
@apply h-1;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
/* stylelint-disable */
|
||||
background-image: -webkit-linear-gradient(
|
||||
-45deg,
|
||||
transparent 20%,
|
||||
rgb(255 255 255 / 40%) 20%,
|
||||
rgb(255 255 255 / 40%) 40%,
|
||||
transparent 40%,
|
||||
transparent 60%,
|
||||
rgb(255 255 255 / 40%) 60%,
|
||||
rgb(255 255 255 / 40%) 80%,
|
||||
transparent 80%
|
||||
),
|
||||
-webkit-linear-gradient(left, var(--color-primary), var(--color-primary));
|
||||
/* stylelint-enable */
|
||||
border-radius: 2px;
|
||||
background-size: 21px 20px, 100% 100%, 100% 100%;
|
||||
}
|
||||
|
||||
progress::-moz-progress-bar {
|
||||
/* stylelint-disable */
|
||||
background-image: -moz-linear-gradient(
|
||||
135deg,
|
||||
transparent 20%,
|
||||
rgb(255 255 255 / 40%) 20%,
|
||||
rgb(255 255 255 / 40%) 40%,
|
||||
transparent 40%,
|
||||
transparent 60%,
|
||||
rgb(255 255 255 / 40%) 60%,
|
||||
rgb(255 255 255 / 40%) 80%,
|
||||
transparent 80%
|
||||
),
|
||||
-moz-linear-gradient(left, var(--color-primary), var(--color-primary));
|
||||
/* stylelint-enable */
|
||||
border-radius: 2px;
|
||||
background-size: 21px 20px, 100% 100%, 100% 100%;
|
||||
animation: animate-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
@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 {
|
||||
.main-header img {
|
||||
height: 48px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.main {
|
||||
@apply flex-1;
|
||||
@apply self-center;
|
||||
@apply items-center;
|
||||
@apply m-auto;
|
||||
@apply py-8;
|
||||
|
||||
max-height: 42rem;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
}
|
||||
|
||||
@screen dark {
|
||||
body {
|
||||
@apply text-grey-10;
|
||||
|
||||
background-image: unset;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply bg-primary;
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@apply bg-primary_accent;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
@apply bg-primary_accent;
|
||||
}
|
||||
|
||||
.link-primary {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.link-primary:hover {
|
||||
@apply text-primary_accent;
|
||||
}
|
||||
|
||||
.link-primary:focus {
|
||||
@apply text-primary_accent;
|
||||
}
|
||||
|
||||
.main > section {
|
||||
@apply bg-grey-90;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.main > section {
|
||||
@apply border-default;
|
||||
@apply border-grey-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
@responsive {
|
||||
.shadow-light {
|
||||
box-shadow: 0 0 8px 0 rgb(12 12 13 / 10%);
|
||||
}
|
||||
|
||||
.shadow-big {
|
||||
box-shadow: 0 12px 18px 2px rgb(34 0 51 / 4%),
|
||||
0 6px 22px 4px rgb(7 48 114 / 12%), 0 6px 10px -4px rgb(14 13 26 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
@variants focus {
|
||||
.outline {
|
||||
outline: 1px dotted grey;
|
||||
}
|
||||
}
|
||||
|
||||
.word-break-all {
|
||||
word-break: break-all;
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.signin {
|
||||
backface-visibility: hidden;
|
||||
border-radius: 6px;
|
||||
transition-property: transform, background-color;
|
||||
transition-duration: 250ms;
|
||||
transition-timing-function: cubic-bezier(0.07, 0.95, 0, 1);
|
||||
}
|
||||
|
||||
.signin:hover,
|
||||
.signin:focus {
|
||||
transform: scale(1.0625);
|
||||
}
|
||||
|
||||
.signin:hover:active {
|
||||
transform: scale(0.9375);
|
||||
}
|
||||
|
||||
/* begin signin button color experiment */
|
||||
|
||||
.white-primary {
|
||||
@apply border-primary;
|
||||
@apply border-2;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.white-primary:hover,
|
||||
.white-primary:focus {
|
||||
@apply bg-primary;
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.primary {
|
||||
@apply bg-primary;
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.white-violet {
|
||||
@apply border-violet;
|
||||
@apply border-2;
|
||||
@apply text-violet;
|
||||
}
|
||||
|
||||
.white-violet:hover,
|
||||
.white-violet:focus {
|
||||
@apply bg-violet;
|
||||
@apply text-white;
|
||||
|
||||
background-image: var(--violet-gradient);
|
||||
}
|
||||
|
||||
.violet {
|
||||
@apply bg-violet;
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.violet:hover,
|
||||
.violet:focus {
|
||||
background-image: var(--violet-gradient);
|
||||
}
|
||||
|
||||
/* end signin button color experiment */
|
||||
@import './base.css';
|
||||
@import './templates/header/header.css';
|
||||
@import './templates/downloadButton/downloadButton.css';
|
||||
@import './templates/progress/progress.css';
|
||||
@import './templates/passwordInput/passwordInput.css';
|
||||
@import './templates/downloadPassword/downloadPassword.css';
|
||||
@import './templates/setPasswordSection/setPasswordSection.css';
|
||||
@import './templates/footer/footer.css';
|
||||
@import './templates/fxPromo/fxPromo.css';
|
||||
@import './templates/selectbox/selectbox.css';
|
||||
@import './templates/fileList/fileList.css';
|
||||
@import './templates/file/file.css';
|
||||
@import './templates/popup/popup.css';
|
||||
@import './pages/welcome/welcome.css';
|
||||
@import './pages/share/share.css';
|
||||
@import './pages/unsupported/unsupported.css';
|
||||
|
|
111
app/main.js
|
@ -1,74 +1,55 @@
|
|||
/* global DEFAULTS LIMITS WEB_UI PREFS */
|
||||
import 'core-js';
|
||||
import 'fast-text-encoding'; // MS Edge support
|
||||
import 'intl-pluralrules';
|
||||
import choo from 'choo';
|
||||
import nanotiming from 'nanotiming';
|
||||
import routes from './routes';
|
||||
import getCapabilities from './capabilities';
|
||||
import controller from './controller';
|
||||
import 'fluent-intl-polyfill';
|
||||
import app from './routes';
|
||||
import locale from '../common/locales';
|
||||
import fileManager from './fileManager';
|
||||
import dragManager from './dragManager';
|
||||
import pasteManager from './pasteManager';
|
||||
import { canHasSend } from './utils';
|
||||
import assets from '../common/assets';
|
||||
import storage from './storage';
|
||||
import metrics from './metrics';
|
||||
import experiments from './experiments';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import './main.css';
|
||||
import User from './user';
|
||||
import { getTranslator } from './locale';
|
||||
import Archive from './archive';
|
||||
import { setTranslate, locale } from './utils';
|
||||
import Raven from 'raven-js';
|
||||
|
||||
if (navigator.doNotTrack !== '1' && window.SENTRY_CONFIG) {
|
||||
Sentry.init(window.SENTRY_CONFIG);
|
||||
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
|
||||
Raven.config(window.SENTRY_ID, window.RAVEN_CONFIG).install();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
nanotiming.disabled = true;
|
||||
}
|
||||
|
||||
(async function start() {
|
||||
const capabilities = await getCapabilities();
|
||||
if (
|
||||
!capabilities.crypto &&
|
||||
window.location.pathname !== '/unsupported/crypto'
|
||||
) {
|
||||
return window.location.assign('/unsupported/crypto');
|
||||
}
|
||||
if (capabilities.serviceWorker) {
|
||||
try {
|
||||
await navigator.serviceWorker.register('/serviceWorker.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
} catch (e) {
|
||||
// continue but disable streaming downloads
|
||||
capabilities.streamDownload = false;
|
||||
app.use((state, emitter) => {
|
||||
state.transfer = null;
|
||||
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';
|
||||
}
|
||||
}
|
||||
if (/edge\/\d+/i.test(navigator.userAgent)) {
|
||||
unsupportedReason = 'edge';
|
||||
}
|
||||
const ok = await canHasSend(assets.get('cryptofill.js'));
|
||||
if (!ok) {
|
||||
unsupportedReason = /firefox/i.test(navigator.userAgent)
|
||||
? 'outdated'
|
||||
: 'gcm';
|
||||
}
|
||||
if (unsupportedReason) {
|
||||
setTimeout(() =>
|
||||
emitter.emit('replaceState', `/unsupported/${unsupportedReason}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const translate = await getTranslator(locale());
|
||||
setTranslate(translate);
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
window.initialState = {
|
||||
LIMITS,
|
||||
DEFAULTS,
|
||||
WEB_UI,
|
||||
PREFS,
|
||||
archive: new Archive([], DEFAULTS.EXPIRE_SECONDS, DEFAULTS.DOWNLOADS),
|
||||
capabilities,
|
||||
translate,
|
||||
storage,
|
||||
sentry: Sentry,
|
||||
user: new User(storage, LIMITS, window.AUTH_CONFIG),
|
||||
transfer: null,
|
||||
fileInfo: null,
|
||||
locale: locale()
|
||||
};
|
||||
app.use(metrics);
|
||||
app.use(fileManager);
|
||||
app.use(dragManager);
|
||||
app.use(experiments);
|
||||
|
||||
const app = routes(choo({ hash: true }));
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
window.app = app;
|
||||
app.use(experiments);
|
||||
app.use(controller);
|
||||
app.use(dragManager);
|
||||
app.use(pasteManager);
|
||||
app.mount('body');
|
||||
})();
|
||||
app.mount('body');
|
||||
|
|
297
app/metrics.js
Normal file
|
@ -0,0 +1,297 @@
|
|||
import testPilotGA from 'testpilot-ga/src/TestPilotGA';
|
||||
import storage from './storage';
|
||||
|
||||
let hasLocalStorage = false;
|
||||
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 experiment = null;
|
||||
|
||||
export default function initialize(state, emitter) {
|
||||
appState = state;
|
||||
emitter.on('DOMContentLoaded', () => {
|
||||
addExitHandlers();
|
||||
experiment = storage.enrolled[0];
|
||||
sendEvent(category(), 'visit', {
|
||||
cm5: storage.totalUploads,
|
||||
cm6: storage.files.length,
|
||||
cm7: storage.totalDownloads
|
||||
});
|
||||
//TODO restart handlers... somewhere
|
||||
});
|
||||
emitter.on('exit', exitEvent);
|
||||
emitter.on('experiment', experimentEvent);
|
||||
}
|
||||
|
||||
function category() {
|
||||
switch (appState.route) {
|
||||
case '/':
|
||||
case '/share/:id':
|
||||
return 'sender';
|
||||
case '/download/:id/:key':
|
||||
case '/download/:id':
|
||||
case '/completed':
|
||||
return 'recipient';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function sendEvent() {
|
||||
const args = Array.from(arguments);
|
||||
if (experiment && args[2]) {
|
||||
args[2].xid = experiment[0];
|
||||
args[2].xvar = experiment[1];
|
||||
}
|
||||
return (
|
||||
hasLocalStorage && analytics.sendEvent.apply(analytics, args).catch(() => 0)
|
||||
);
|
||||
}
|
||||
|
||||
function urlToMetric(url) {
|
||||
switch (url) {
|
||||
case 'https://www.mozilla.org/':
|
||||
return 'mozilla';
|
||||
case 'https://www.mozilla.org/about/legal':
|
||||
return 'legal';
|
||||
case 'https://testpilot.firefox.com/about':
|
||||
return 'about';
|
||||
case 'https://testpilot.firefox.com/privacy':
|
||||
return 'privacy';
|
||||
case 'https://testpilot.firefox.com/terms':
|
||||
return 'terms';
|
||||
case 'https://www.mozilla.org/privacy/websites/#cookies':
|
||||
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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function externalReferrer() {
|
||||
if (/^https:\/\/testpilot\.firefox\.com/.test(document.referrer)) {
|
||||
return 'testpilot';
|
||||
}
|
||||
return 'external';
|
||||
}
|
||||
|
||||
function takeReferrer() {
|
||||
const referrer = storage.referrer || externalReferrer();
|
||||
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) {
|
||||
setReferrer('cancelled');
|
||||
return sendEvent('sender', 'upload-stopped', {
|
||||
cm1: params.size,
|
||||
cm5: storage.totalUploads,
|
||||
cm6: storage.files.length,
|
||||
cm7: storage.totalDownloads,
|
||||
cd1: params.type,
|
||||
cd2: 'cancelled'
|
||||
});
|
||||
}
|
||||
|
||||
function completedUpload(params) {
|
||||
return sendEvent('sender', 'upload-stopped', {
|
||||
cm1: params.size,
|
||||
cm2: params.time,
|
||||
cm3: params.speed,
|
||||
cm5: storage.totalUploads,
|
||||
cm6: storage.files.length,
|
||||
cm7: storage.totalDownloads,
|
||||
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) {
|
||||
return sendEvent('recipient', 'download-stopped', {
|
||||
cm1: params.size,
|
||||
cm5: storage.totalUploads,
|
||||
cm6: storage.files.length,
|
||||
cm7: storage.totalDownloads,
|
||||
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) {
|
||||
return sendEvent('recipient', 'download-stopped', {
|
||||
cm1: params.size,
|
||||
cm2: params.time,
|
||||
cm3: params.speed,
|
||||
cm5: storage.totalUploads,
|
||||
cm6: storage.files.length,
|
||||
cm7: storage.totalDownloads,
|
||||
cd2: 'completed'
|
||||
});
|
||||
}
|
||||
|
||||
function deletedUpload(params) {
|
||||
return sendEvent(category(), 'upload-deleted', {
|
||||
cm1: params.size,
|
||||
cm2: params.time,
|
||||
cm3: params.speed,
|
||||
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) {
|
||||
return sendEvent(category(), 'experiment', params);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function addExitHandlers() {
|
||||
const links = Array.from(document.querySelectorAll('a'));
|
||||
links.forEach(l => {
|
||||
if (/^http/.test(l.getAttribute('href'))) {
|
||||
l.addEventListener('click', exitEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function restart(state) {
|
||||
setReferrer(state);
|
||||
return sendEvent(category(), 'restarted', {
|
||||
cd2: state
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
copiedLink,
|
||||
startedUpload,
|
||||
cancelledUpload,
|
||||
stoppedUpload,
|
||||
completedUpload,
|
||||
changedDownloadLimit,
|
||||
deletedUpload,
|
||||
startedDownload,
|
||||
cancelledDownload,
|
||||
stoppedDownload,
|
||||
completedDownload,
|
||||
addedPassword,
|
||||
restart,
|
||||
unsupported
|
||||
};
|
|
@ -4,14 +4,11 @@ import { del, fileInfo, setParams, setPassword } from './api';
|
|||
|
||||
export default class OwnedFile {
|
||||
constructor(obj) {
|
||||
if (!obj.manifest) {
|
||||
throw new Error('invalid file object');
|
||||
}
|
||||
this.id = obj.id;
|
||||
this.url = obj.url;
|
||||
this.name = obj.name;
|
||||
this.size = obj.size;
|
||||
this.manifest = obj.manifest;
|
||||
this.type = obj.type;
|
||||
this.time = obj.time;
|
||||
this.speed = obj.speed;
|
||||
this.createdAt = obj.createdAt;
|
||||
|
@ -21,15 +18,6 @@ export default class OwnedFile {
|
|||
this.dtotal = obj.dtotal || 0;
|
||||
this.keychain = new Keychain(obj.secretKey, obj.nonce);
|
||||
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) {
|
||||
|
@ -50,17 +38,19 @@ export default class OwnedFile {
|
|||
return del(this.id, this.ownerToken);
|
||||
}
|
||||
|
||||
changeLimit(dlimit, user = {}) {
|
||||
changeLimit(dlimit) {
|
||||
if (this.dlimit !== dlimit) {
|
||||
this.dlimit = dlimit;
|
||||
return setParams(this.id, this.ownerToken, user.bearerToken, { dlimit });
|
||||
return setParams(this.id, this.ownerToken, { dlimit });
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
get hasPassword() {
|
||||
return !!this._hasPassword;
|
||||
}
|
||||
|
||||
async updateDownloadCount() {
|
||||
const oldTotal = this.dtotal;
|
||||
const oldLimit = this.dlimit;
|
||||
try {
|
||||
const result = await fileInfo(this.id, this.ownerToken);
|
||||
this.dtotal = result.dtotal;
|
||||
|
@ -71,7 +61,6 @@ export default class OwnedFile {
|
|||
}
|
||||
// ignore other errors
|
||||
}
|
||||
return oldTotal !== this.dtotal || oldLimit !== this.dlimit;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
@ -80,7 +69,7 @@ export default class OwnedFile {
|
|||
url: this.url,
|
||||
name: this.name,
|
||||
size: this.size,
|
||||
manifest: this.manifest,
|
||||
type: this.type,
|
||||
time: this.time,
|
||||
speed: this.speed,
|
||||
createdAt: this.createdAt,
|
||||
|
@ -89,8 +78,7 @@ export default class OwnedFile {
|
|||
ownerToken: this.ownerToken,
|
||||
dlimit: this.dlimit,
|
||||
dtotal: this.dtotal,
|
||||
hasPassword: this.hasPassword,
|
||||
timeLimit: this.timeLimit
|
||||
hasPassword: this.hasPassword
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
5
app/pages/blank.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const html = require('choo/html');
|
||||
|
||||
module.exports = function() {
|
||||
return html`<div></div>`;
|
||||
};
|
26
app/pages/completed/index.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
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', '/');
|
||||
}
|
||||
};
|
42
app/pages/download/index.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
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');
|
||||
}
|
||||
};
|
10
app/pages/error/index.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
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>`;
|
||||
};
|
32
app/pages/legal.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
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>`;
|
||||
}
|
16
app/pages/notFound/index.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
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>`;
|
||||
};
|
40
app/pages/preview/index.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
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>
|
||||
`;
|
||||
};
|
115
app/pages/share/index.js
Normal file
|
@ -0,0 +1,115 @@
|
|||
/* 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">
|
||||
<div class="title">${expireInfo(file, state.translate, emit)}</div>
|
||||
<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>${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 });
|
||||
select.parentNode.replaceChild(
|
||||
selectbox(file.dlimit || 1, options, t, changed),
|
||||
select
|
||||
);
|
||||
return el;
|
||||
}
|
112
app/pages/share/share.css
Normal file
|
@ -0,0 +1,112 @@
|
|||
.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;
|
||||
}
|
||||
}
|
67
app/pages/unsupported/index.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
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')
|
||||
};
|
||||
}
|
49
app/pages/unsupported/unsupported.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
.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;
|
||||
}
|
39
app/pages/upload/index.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
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');
|
||||
}
|
||||
};
|
83
app/pages/welcome/index.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
/* 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) {
|
||||
window.alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
|
||||
return;
|
||||
}
|
||||
|
||||
await fadeOut('#page-one');
|
||||
emit('upload', { file, type: 'click' });
|
||||
}
|
||||
};
|
65
app/pages/welcome/welcome.css
Normal file
|
@ -0,0 +1,65 @@
|
|||
.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;
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
function getString(item) {
|
||||
return new Promise(resolve => {
|
||||
item.getAsString(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
export default function(state, emitter) {
|
||||
window.addEventListener('paste', async event => {
|
||||
if (state.route !== '/' || state.uploading) return;
|
||||
if (['password', 'text', 'email'].includes(event.target.type)) return;
|
||||
|
||||
const items = Array.from(event.clipboardData.items);
|
||||
const transferFiles = items.filter(item => item.kind === 'file');
|
||||
const strings = items.filter(item => item.kind === 'string');
|
||||
if (transferFiles.length) {
|
||||
const promises = transferFiles.map(async (f, i) => {
|
||||
const blob = f.getAsFile();
|
||||
if (!blob) {
|
||||
return null;
|
||||
}
|
||||
const name = await getString(strings[i]);
|
||||
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) {
|
||||
strings[0].getAsString(s => {
|
||||
const file = new File([s], 'pasted.txt', { type: 'text/plain' });
|
||||
emitter.emit('addFiles', { files: [file] });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
2345
app/qrcode.js
|
@ -1,9 +0,0 @@
|
|||
# Application Code
|
||||
|
||||
`app/` contains the browser code that gets bundled into `app.[hash].js`. It's got all the logic, crypto, and UI. All of it gets used in the browser, and some of it by the server for server side rendering.
|
||||
|
||||
The main entrypoint for the browser is [main.js](./main.js) and on the server [routes.js](./routes.js) is imported by [/server/routes/pages.js](../server/routes/pages.js)
|
||||
|
||||
- `pages` contains display logic an markup for pages
|
||||
- `routes` contains route definitions and logic
|
||||
- `templates` contains ui elements smaller than pages
|
|
@ -1,21 +0,0 @@
|
|||
const choo = require('choo');
|
||||
const download = require('./ui/download');
|
||||
const body = require('./ui/body');
|
||||
|
||||
module.exports = function(app = choo({ hash: true })) {
|
||||
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('/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('/login', function(state, emit) {
|
||||
emit('replaceState', '/');
|
||||
setTimeout(() => emit('render'));
|
||||
});
|
||||
app.route('*', body(require('./ui/notFound')));
|
||||
return app;
|
||||
};
|
60
app/routes/download.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
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);
|
||||
};
|
9
app/routes/home.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
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);
|
||||
};
|
58
app/routes/index.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
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;
|
|
@ -1,174 +0,0 @@
|
|||
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)(#\w+)?$/;
|
||||
const DOWNLOAD_URL = /\/api\/download\/([A-Fa-f0-9]{4,})/;
|
||||
const FONT = /\.woff2?$/;
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(self.clients.claim().then(precache));
|
||||
});
|
||||
|
||||
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() {
|
||||
try {
|
||||
await cleanCache();
|
||||
const cache = await caches.open(version);
|
||||
const images = assets.match(IMAGES);
|
||||
await cache.addAll(images);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// cache will get populated on demand
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanCache() {
|
||||
const oldCaches = await caches.keys();
|
||||
for (const c of oldCaches) {
|
||||
if (c !== version) {
|
||||
await caches.delete(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cacheable(url) {
|
||||
return VERSIONED_ASSET.test(url) || FONT.test(url);
|
||||
}
|
||||
|
||||
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 && cacheable(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 (cacheable(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');
|
||||
}
|
||||
};
|