mirror of
https://gitlab.com/timvisee/send.git
synced 2025-12-06 14:10:53 +03:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dafdcdebd | ||
|
|
dc03b42b96 | ||
|
|
3e07f648b3 | ||
|
|
f58597cece | ||
|
|
d3f9b82672 | ||
|
|
a0bc20aeb6 | ||
|
|
d03e83dd66 | ||
|
|
94e80ccee9 | ||
|
|
f8358c4dac | ||
|
|
ec3cff63a2 | ||
|
|
8f192482b5 | ||
|
|
808a04b669 | ||
|
|
71a925a674 | ||
|
|
64d9cd694d | ||
|
|
94b78b425f | ||
|
|
42e94139a2 | ||
|
|
6bd6280fb5 | ||
|
|
1f2c524b40 | ||
|
|
854810c242 | ||
|
|
45024d3dc6 |
@@ -1,4 +1,4 @@
|
||||
image: "node:12-slim"
|
||||
image: "node:15-slim"
|
||||
|
||||
stages:
|
||||
- test
|
||||
@@ -17,6 +17,7 @@ before_script:
|
||||
- apt-get update
|
||||
- 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
|
||||
|
||||
# Build Send, run npm tests
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
@@ -24,48 +25,81 @@ test:
|
||||
- npm run lint
|
||||
- npm test
|
||||
|
||||
# Release Docker image artifact for easy testing
|
||||
# Build Docker image, export Docker image artifact
|
||||
artifact-docker:
|
||||
stage: artifact
|
||||
image: docker:latest
|
||||
needs: []
|
||||
services:
|
||||
- docker:dind
|
||||
variables:
|
||||
IMG_FILE: "send:git-$CI_COMMIT_SHORT_SHA.tar"
|
||||
IMG_NAME: "send:git-$CI_COMMIT_SHORT_SHA"
|
||||
before_script: []
|
||||
script:
|
||||
- docker build -t $IMG_NAME .
|
||||
- docker image save -o $IMG_FILE $IMG_NAME
|
||||
artifacts:
|
||||
name: artifact-docker
|
||||
paths:
|
||||
- $IMG_FILE
|
||||
expire_in: 1 week
|
||||
|
||||
# Release public Docker image for the master branch
|
||||
release-docker-master:
|
||||
stage: release
|
||||
image: docker:latest
|
||||
dependencies:
|
||||
- artifact-docker
|
||||
services:
|
||||
- docker:dind
|
||||
only:
|
||||
- master
|
||||
variables:
|
||||
IMG_IMPORT_FILE: "send:git-$CI_COMMIT_SHORT_SHA.tar"
|
||||
IMG_IMPORT_NAME: "send:git-$CI_COMMIT_SHORT_SHA"
|
||||
IMG_NAME: "registry.gitlab.com/timvisee/send:master-$CI_COMMIT_SHORT_SHA"
|
||||
before_script: []
|
||||
script:
|
||||
- export IMG_NAME=registry.gitlab.com/timvisee/send:master-$CI_COMMIT_SHA
|
||||
|
||||
# Login in to registry
|
||||
- 'docker login registry.gitlab.com -u $DOCKER_USER -p $DOCKER_PASS'
|
||||
|
||||
# Build and push image, report image name
|
||||
- docker build -t $IMG_NAME .
|
||||
- docker push $IMG_NAME
|
||||
- 'echo Docker image artifact published, available as:'
|
||||
- 'echo " docker pull $IMG_NAME"'
|
||||
# Load existing, retag for new image images
|
||||
- docker image load -i $IMG_IMPORT_FILE
|
||||
- docker tag $IMG_IMPORT_NAME $IMG_NAME
|
||||
|
||||
# Release public Docker image
|
||||
# Publish tagged image
|
||||
- docker push $IMG_NAME
|
||||
|
||||
- 'echo "Docker image artifact published, available as:" && echo " docker pull $IMG_NAME"'
|
||||
|
||||
# Release public Docker image for a version tag
|
||||
release-docker:
|
||||
stage: release
|
||||
image: docker:latest
|
||||
dependencies:
|
||||
- artifact-docker
|
||||
services:
|
||||
- docker:dind
|
||||
only:
|
||||
- /^v(\d+\.)*\d+$/
|
||||
variables:
|
||||
IMG_IMPORT_FILE: "send:git-$CI_COMMIT_SHORT_SHA.tar"
|
||||
IMG_IMPORT_NAME: "send:git-$CI_COMMIT_SHORT_SHA"
|
||||
IMG_NAME: "registry.gitlab.com/timvisee/send:$CI_COMMIT_REF_NAME"
|
||||
IMG_NAME_LATEST: "registry.gitlab.com/timvisee/send:latest"
|
||||
before_script: []
|
||||
script:
|
||||
- export IMG_NAME=registry.gitlab.com/timvisee/send:$CI_COMMIT_REF_NAME
|
||||
- export IMG_NAME_LATEST=registry.gitlab.com/timvisee/send:latest
|
||||
|
||||
# Login in to registry
|
||||
- 'docker login registry.gitlab.com -u $DOCKER_USER -p $DOCKER_PASS'
|
||||
|
||||
# Build and push image, report image name
|
||||
- docker build -t $IMG_NAME .
|
||||
- docker tag $IMG_NAME $IMG_NAME_LATEST
|
||||
# Load existing, retag for new image images
|
||||
- docker image load -i $IMG_IMPORT_FILE
|
||||
- docker tag $IMG_IMPORT_NAME $IMG_NAME
|
||||
- docker tag $IMG_IMPORT_NAME $IMG_NAME_LATEST
|
||||
|
||||
# Publish tagged image
|
||||
- docker push $IMG_NAME
|
||||
- docker push $IMG_NAME_LATEST
|
||||
- 'echo Docker image artifact published, available as:'
|
||||
- 'echo " docker pull $IMG_NAME_LATEST"'
|
||||
- 'echo " docker pull $IMG_NAME"'
|
||||
|
||||
- 'echo "Docker image artifact published, available as:" && echo " docker pull $IMG_NAME_LATEST" && echo " docker pull $IMG_NAME"'
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -6,13 +6,13 @@
|
||||
|
||||
|
||||
# Build project
|
||||
FROM node:12 AS builder
|
||||
FROM node:15.5.1-alpine AS builder
|
||||
RUN set -x \
|
||||
# Add user
|
||||
&& addgroup --gid 10001 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--gid 10001 \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
@@ -26,19 +26,17 @@ RUN set -x \
|
||||
|
||||
|
||||
# Main image
|
||||
FROM node:12-slim
|
||||
FROM node:15.5.1-alpine
|
||||
RUN set -x \
|
||||
# Add user
|
||||
&& addgroup --gid 10001 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--gid 10001 \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
RUN apt-get update && apt-get -y install \
|
||||
git-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY --chown=app:app package*.json ./
|
||||
|
||||
13
README.md
13
README.md
@@ -53,7 +53,7 @@ Thanks [Mozilla][mozilla] for building this amazing tool!
|
||||
|
||||
---
|
||||
|
||||
**Docs:** [FAQ](docs/faq.md), [Encryption](docs/encryption.md), [Build](docs/build.md), [Docker](docs/docker.md), [Metrics](docs/metrics.md), [More](docs/)
|
||||
**Docs:** [FAQ](docs/faq.md), [Encryption](docs/encryption.md), [Build](docs/build.md), [Docker](docs/docker.md), [More](docs/)
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +66,6 @@ Thanks [Mozilla][mozilla] for building this amazing tool!
|
||||
* [Configuration](#configuration)
|
||||
* [Localization](#localization)
|
||||
* [Contributing](#contributing)
|
||||
* [Testing](#testing)
|
||||
* [Deployment](#deployment)
|
||||
* [Clients](#clients)
|
||||
* [License](#license)
|
||||
@@ -133,16 +132,6 @@ Pull requests are always welcome! Feel free to check out the list of ["good firs
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| ENVIRONMENT | URL
|
||||
|-------------|-----
|
||||
| Production | <https://send.firefox.com/>
|
||||
| Stage | <https://stage.send.nonprod.cloudops.mozgcp.net/>
|
||||
| Development | <https://send2.dev.lcip.org/>
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
see also [docs/deployment.md](docs/deployment.md)
|
||||
|
||||
@@ -4,7 +4,6 @@ import html from 'choo/html';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
import { setApiUrlPrefix, getConstants } from '../app/api';
|
||||
import metrics from '../app/metrics';
|
||||
//import assets from '../common/assets';
|
||||
import Archive from '../app/archive';
|
||||
import Header from '../app/ui/header';
|
||||
@@ -83,7 +82,6 @@ function body(main) {
|
||||
state.user = new User(storage, LIMITS);
|
||||
state.sentry = Sentry;
|
||||
});
|
||||
app.use(metrics);
|
||||
app.route('/', body(home));
|
||||
app.route('/upload', upload);
|
||||
app.route('/share/:id', share);
|
||||
|
||||
11
app/api.js
11
app/api.js
@@ -420,17 +420,6 @@ export async function setFileList(bearerToken, kid, data) {
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export function sendMetrics(blob) {
|
||||
if (!navigator.sendBeacon) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
navigator.sendBeacon(getApiUrl('/api/metrics'), blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConstants() {
|
||||
const response = await fetch(getApiUrl('/config'));
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as metrics from './metrics';
|
||||
import FileReceiver from './fileReceiver';
|
||||
import FileSender from './fileSender';
|
||||
import copyDialog from './ui/copyDialog';
|
||||
@@ -54,7 +53,6 @@ export default function(state, emitter) {
|
||||
|
||||
emitter.on('logout', async () => {
|
||||
await state.user.logout();
|
||||
metrics.loggedOut({ trigger: 'button' });
|
||||
emitter.emit('pushState', '/');
|
||||
});
|
||||
|
||||
@@ -68,14 +66,6 @@ export default function(state, emitter) {
|
||||
|
||||
emitter.on('delete', async ownedFile => {
|
||||
try {
|
||||
metrics.deletedUpload({
|
||||
size: ownedFile.size,
|
||||
time: ownedFile.time,
|
||||
speed: ownedFile.speed,
|
||||
type: ownedFile.type,
|
||||
ttl: ownedFile.expiresAt - Date.now(),
|
||||
location
|
||||
});
|
||||
state.storage.remove(ownedFile.id);
|
||||
await ownedFile.del();
|
||||
} catch (e) {
|
||||
@@ -101,9 +91,6 @@ export default function(state, emitter) {
|
||||
state.LIMITS.MAX_FILES_PER_ARCHIVE
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.message === 'fileTooBig' && maxSize < state.LIMITS.MAX_FILE_SIZE) {
|
||||
return emitter.emit('signup-cta', 'size');
|
||||
}
|
||||
state.modal = okDialog(
|
||||
state.translate(e.message, {
|
||||
size: bytes(maxSize),
|
||||
@@ -123,7 +110,7 @@ export default function(state, emitter) {
|
||||
source: query.utm_source,
|
||||
term: query.utm_term
|
||||
});
|
||||
state.modal = signupDialog(source);
|
||||
state.modal = signupDialog();
|
||||
render();
|
||||
});
|
||||
|
||||
@@ -159,12 +146,9 @@ export default function(state, emitter) {
|
||||
|
||||
const links = openLinksInNewTab();
|
||||
await delay(200);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const ownedFile = await sender.upload(archive, state.user.bearerToken);
|
||||
state.storage.totalUploads += 1;
|
||||
const duration = Date.now() - start;
|
||||
metrics.completedUpload(archive, duration);
|
||||
faviconProgressbar.updateFavicon(0);
|
||||
|
||||
state.storage.addFile(ownedFile);
|
||||
@@ -181,7 +165,6 @@ export default function(state, emitter) {
|
||||
} catch (err) {
|
||||
if (err.message === '0') {
|
||||
//cancelled. do nothing
|
||||
metrics.cancelledUpload(archive, err.duration);
|
||||
render();
|
||||
} else if (err.message === '401') {
|
||||
const refreshed = await state.user.refresh();
|
||||
@@ -197,7 +180,6 @@ export default function(state, emitter) {
|
||||
scope.setExtra('size', err.size);
|
||||
state.sentry.captureException(err);
|
||||
});
|
||||
metrics.stoppedUpload(archive, err.duration);
|
||||
emitter.emit('pushState', '/error');
|
||||
}
|
||||
} finally {
|
||||
@@ -249,13 +231,11 @@ export default function(state, emitter) {
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('download', async file => {
|
||||
emitter.on('download', async () => {
|
||||
state.transfer.on('progress', updateProgress);
|
||||
state.transfer.on('decrypting', render);
|
||||
state.transfer.on('complete', render);
|
||||
const links = openLinksInNewTab();
|
||||
const size = file.size;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const dl = state.transfer.download({
|
||||
stream: state.capabilities.streamDownload
|
||||
@@ -263,12 +243,6 @@ export default function(state, emitter) {
|
||||
render();
|
||||
await dl;
|
||||
state.storage.totalDownloads += 1;
|
||||
const duration = Date.now() - start;
|
||||
metrics.completedDownload({
|
||||
size,
|
||||
duration,
|
||||
password_protected: file.requiresPassword
|
||||
});
|
||||
faviconProgressbar.updateFavicon(0);
|
||||
} catch (err) {
|
||||
if (err.message === '0') {
|
||||
@@ -286,12 +260,6 @@ export default function(state, emitter) {
|
||||
scope.setExtra('progress', err.progress);
|
||||
state.sentry.captureException(err);
|
||||
});
|
||||
const duration = Date.now() - start;
|
||||
metrics.stoppedDownload({
|
||||
size,
|
||||
duration,
|
||||
password_protected: file.requiresPassword
|
||||
});
|
||||
}
|
||||
emitter.emit('pushState', location);
|
||||
}
|
||||
@@ -302,7 +270,6 @@ export default function(state, emitter) {
|
||||
|
||||
emitter.on('copy', ({ url }) => {
|
||||
copyToClipboard(url);
|
||||
// metrics.copiedLink({ location });
|
||||
});
|
||||
|
||||
emitter.on('closeModal', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import controller from './controller';
|
||||
import dragManager from './dragManager';
|
||||
import pasteManager from './pasteManager';
|
||||
import storage from './storage';
|
||||
import metrics from './metrics';
|
||||
import experiments from './experiments';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import './main.css';
|
||||
@@ -68,7 +67,6 @@ if (process.env.NODE_ENV === 'production') {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
window.app = app;
|
||||
app.use(experiments);
|
||||
app.use(metrics);
|
||||
app.use(controller);
|
||||
app.use(dragManager);
|
||||
app.use(pasteManager);
|
||||
|
||||
186
app/metrics.js
186
app/metrics.js
@@ -1,186 +0,0 @@
|
||||
import storage from './storage';
|
||||
import { platform, locale } from './utils';
|
||||
import { sendMetrics } from './api';
|
||||
|
||||
let appState = null;
|
||||
let experiment = null;
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const events = [];
|
||||
let session_id = Date.now();
|
||||
const lang = locale();
|
||||
|
||||
export default function initialize(state, emitter) {
|
||||
appState = state;
|
||||
|
||||
emitter.on('DOMContentLoaded', () => {
|
||||
experiment = storage.enrolled;
|
||||
if (!appState.user.firstAction) {
|
||||
appState.user.firstAction =
|
||||
appState.route === '/' ? 'upload' : 'download';
|
||||
}
|
||||
const query = appState.query;
|
||||
addEvent('client_visit', {
|
||||
entrypoint: appState.route === '/' ? 'upload' : 'download',
|
||||
referrer: document.referrer,
|
||||
utm_campaign: query.utm_campaign,
|
||||
utm_content: query.utm_content,
|
||||
utm_medium: query.utm_medium,
|
||||
utm_source: query.utm_source,
|
||||
utm_term: query.utm_term
|
||||
});
|
||||
});
|
||||
emitter.on('experiment', experimentEvent);
|
||||
window.addEventListener('unload', submitEvents);
|
||||
}
|
||||
|
||||
function sizeOrder(n) {
|
||||
return Math.floor(Math.log10(n));
|
||||
}
|
||||
|
||||
function submitEvents() {
|
||||
if (navigator.doNotTrack === '1') {
|
||||
return;
|
||||
}
|
||||
sendMetrics(
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
now: Date.now(),
|
||||
session_id,
|
||||
lang,
|
||||
platform: platform(),
|
||||
events
|
||||
})
|
||||
],
|
||||
{ type: 'text/plain' } // see http://crbug.com/490015
|
||||
)
|
||||
);
|
||||
events.splice(0);
|
||||
}
|
||||
|
||||
async function addEvent(event_type, event_properties) {
|
||||
const user_id = await appState.user.metricId();
|
||||
const device_id = await appState.user.deviceId();
|
||||
const ab_id = Object.keys(experiment)[0];
|
||||
if (ab_id) {
|
||||
event_properties.experiment = ab_id;
|
||||
event_properties.variant = experiment[ab_id];
|
||||
}
|
||||
events.push({
|
||||
device_id,
|
||||
event_properties,
|
||||
event_type,
|
||||
time: Date.now(),
|
||||
user_id,
|
||||
user_properties: {
|
||||
anonymous: !appState.user.loggedIn,
|
||||
first_action: appState.user.firstAction,
|
||||
active_count: storage.files.length
|
||||
}
|
||||
});
|
||||
if (events.length === 25) {
|
||||
submitEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelledUpload(archive, duration) {
|
||||
return addEvent('client_upload', {
|
||||
download_limit: archive.dlimit,
|
||||
duration: sizeOrder(duration),
|
||||
file_count: archive.numFiles,
|
||||
password_protected: !!archive.password,
|
||||
size: sizeOrder(archive.size),
|
||||
status: 'cancel',
|
||||
time_limit: archive.timeLimit
|
||||
});
|
||||
}
|
||||
|
||||
function completedUpload(archive, duration) {
|
||||
return addEvent('client_upload', {
|
||||
download_limit: archive.dlimit,
|
||||
duration: sizeOrder(duration),
|
||||
file_count: archive.numFiles,
|
||||
password_protected: !!archive.password,
|
||||
size: sizeOrder(archive.size),
|
||||
status: 'ok',
|
||||
time_limit: archive.timeLimit
|
||||
});
|
||||
}
|
||||
|
||||
function stoppedUpload(archive, duration = 0) {
|
||||
return addEvent('client_upload', {
|
||||
download_limit: archive.dlimit,
|
||||
duration: sizeOrder(duration),
|
||||
file_count: archive.numFiles,
|
||||
password_protected: !!archive.password,
|
||||
size: sizeOrder(archive.size),
|
||||
status: 'error',
|
||||
time_limit: archive.timeLimit
|
||||
});
|
||||
}
|
||||
|
||||
function stoppedDownload(params) {
|
||||
return addEvent('client_download', {
|
||||
duration: sizeOrder(params.duration),
|
||||
password_protected: params.password_protected,
|
||||
size: sizeOrder(params.size),
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
function completedDownload(params) {
|
||||
return addEvent('client_download', {
|
||||
duration: sizeOrder(params.duration),
|
||||
password_protected: params.password_protected,
|
||||
size: sizeOrder(params.size),
|
||||
status: 'ok'
|
||||
});
|
||||
}
|
||||
|
||||
function deletedUpload(ownedFile) {
|
||||
return addEvent('client_delete', {
|
||||
age: Math.floor((Date.now() - ownedFile.createdAt) / HOUR),
|
||||
downloaded: ownedFile.dtotal > 0,
|
||||
status: 'ok'
|
||||
});
|
||||
}
|
||||
|
||||
function experimentEvent(params) {
|
||||
return addEvent('client_experiment', params);
|
||||
}
|
||||
|
||||
function submittedSignup(params) {
|
||||
return addEvent('client_login', {
|
||||
status: 'ok',
|
||||
trigger: params.trigger
|
||||
});
|
||||
}
|
||||
|
||||
function canceledSignup(params) {
|
||||
return addEvent('client_login', {
|
||||
status: 'cancel',
|
||||
trigger: params.trigger
|
||||
});
|
||||
}
|
||||
|
||||
function loggedOut(params) {
|
||||
addEvent('client_logout', {
|
||||
status: 'ok',
|
||||
trigger: params.trigger
|
||||
});
|
||||
// flush events and start new anon session
|
||||
submitEvents();
|
||||
session_id = Date.now();
|
||||
}
|
||||
|
||||
export {
|
||||
cancelledUpload,
|
||||
stoppedUpload,
|
||||
completedUpload,
|
||||
deletedUpload,
|
||||
stoppedDownload,
|
||||
completedDownload,
|
||||
submittedSignup,
|
||||
canceledSignup,
|
||||
loggedOut
|
||||
};
|
||||
@@ -580,7 +580,7 @@ module.exports.preview = function(state, emit) {
|
||||
function download(event) {
|
||||
event.preventDefault();
|
||||
event.target.disabled = true;
|
||||
emit('download', archive);
|
||||
emit('download');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ module.exports = function(state, emit) {
|
||||
);
|
||||
break;
|
||||
case 'download':
|
||||
emit('download', archive);
|
||||
emit('download');
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const html = require('choo/html');
|
||||
const assets = require('../../common/assets');
|
||||
const { bytes } = require('../utils');
|
||||
const { canceledSignup, submittedSignup } = require('../metrics');
|
||||
|
||||
module.exports = function(trigger) {
|
||||
module.exports = function() {
|
||||
return function(state, emit, close) {
|
||||
const DAYS = Math.floor(state.LIMITS.MAX_EXPIRE_SECONDS / 86400);
|
||||
let submitting = false;
|
||||
@@ -72,7 +71,6 @@ module.exports = function(trigger) {
|
||||
}
|
||||
|
||||
function cancel(event) {
|
||||
canceledSignup({ trigger });
|
||||
close(event);
|
||||
}
|
||||
|
||||
@@ -85,7 +83,6 @@ module.exports = function(trigger) {
|
||||
|
||||
const el = document.getElementById('email-input');
|
||||
const email = el.value;
|
||||
submittedSignup({ trigger });
|
||||
emit('login', emailish(email) ? email : null);
|
||||
}
|
||||
};
|
||||
|
||||
23
app/user.js
23
app/user.js
@@ -109,27 +109,8 @@ export default class User {
|
||||
async startAuthFlow(trigger, utms = {}) {
|
||||
this.utms = utms;
|
||||
this.trigger = trigger;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
entrypoint: `send-${trigger}`,
|
||||
form_type: 'email',
|
||||
utm_source: utms.source || 'send',
|
||||
utm_campaign: utms.campaign || 'none'
|
||||
});
|
||||
const res = await fetch(
|
||||
`${this.authConfig.issuer}/metrics-flow?${params.toString()}`,
|
||||
{
|
||||
mode: 'cors'
|
||||
}
|
||||
);
|
||||
const { flowId, flowBeginTime } = await res.json();
|
||||
this.flowId = flowId;
|
||||
this.flowBeginTime = flowBeginTime;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.flowId = null;
|
||||
this.flowBeginTime = null;
|
||||
}
|
||||
this.flowId = null;
|
||||
this.flowBeginTime = null;
|
||||
}
|
||||
|
||||
async login(email) {
|
||||
|
||||
@@ -40,6 +40,7 @@ Of course, we don't want to expose the service on port 1443. Instead we want our
|
||||
* a2enmod proxy
|
||||
* a2enmod proxy_http
|
||||
* a2enmod proxy_wstunnel
|
||||
* a2enmod rewrite
|
||||
|
||||
In your Apache virtual host configuration file, insert this:
|
||||
|
||||
|
||||
128
docs/metrics.md
128
docs/metrics.md
@@ -1,128 +0,0 @@
|
||||
# Send V2 Metrics Definitions
|
||||
|
||||
## Key Value Prop
|
||||
|
||||
Quickly and privately transfer large files from any device to any device.
|
||||
|
||||
## Key Business Question to Answer
|
||||
|
||||
Is the value proposition of a large encrypted file transfer service enough to drive Firefox Account relationships for non-Firefox users.
|
||||
|
||||
## Hypotheses to Test
|
||||
|
||||
### Primary - In support of Relationships KPI
|
||||
|
||||
We believe that a privacy-respecting file transfer service can drive Firefox Accounts beyond the Firefox Browser.
|
||||
|
||||
We will know this to be true when we see 250k Firefox Account creations from non-Firefox contexts w/in six months of launch.
|
||||
|
||||
### Secondary - In support of Revenue KPI
|
||||
|
||||
We believe that a privacy respecting service accessible beyond the reach of Firefox will provide a valuable platform to research, communicate with, and market to conscious choosers we have traditionally found hard to reach.
|
||||
|
||||
We will know this to be true when we can conduct six research tasks (surveys, A/B tests, fake doors, etc) in support of premium services KPIs in the first six months after launch.
|
||||
|
||||
## Overview of Key Measures
|
||||
|
||||
* Number of people using the service to send and receive files
|
||||
* Why: measure of service size. Important for understanding addressable market size
|
||||
* Percent of users who have or create an FxAccount via Send
|
||||
* Why: representation of % of any service users who might be amenable to an upsell
|
||||
* % of downloaders who convert into uploaders
|
||||
* Why: represents a measure of our key growth-loop potential
|
||||
* Count of uploads and size
|
||||
* Why: Represents cost of service on a running basis
|
||||
|
||||
## Key Funnels
|
||||
* App Open or Visit `--- DESIRED OUTCOME --->` Successful Upload
|
||||
* Download UI Visit `--- DESIRED OUTCOME --->` Successful Download
|
||||
* FxA UI Engagement `--- DESIRED OUTCOME --->` Authenticate
|
||||
* **STRETCH** App Open or Visit `--- DESIRED OUTCOME --->` Successful Download
|
||||
|
||||
## Amplitude Schema
|
||||
|
||||
Please see, **See Amplitude HTTP API**(https://amplitude.zendesk.com/hc/en-us/articles/204771828) for HTTP API reference.
|
||||
|
||||
## Metric Events
|
||||
|
||||
In support of our KPIs we collect events from two separate contexts, server and client. The events are designed to have minimal correlation between contexts.
|
||||
|
||||
Server events collect lifecycle information about individual uploads but no user information; also time precision is truncated to hour increments. Client events collect information about how users interact with the UI but no upload identifiers.
|
||||
|
||||
### Server Events
|
||||
|
||||
Server events allow us to aggregate data about file lifecycle without collecting data about individual users. In this context `user_id` and `user_properties` describe the uploaded archive.
|
||||
|
||||
* `session_id` -1 (not part of a session)
|
||||
* `user_id` hash of (archive_id + owner_id)
|
||||
* `app_version` package.json version
|
||||
* `time` timestamp truncated to hour precision
|
||||
* `country`
|
||||
* `region`
|
||||
* `event_type` [server_upload | server_download | server_delete]
|
||||
* `user_properties`
|
||||
* `download_limit` set number of downloads
|
||||
* `time_limit` set expiry duration
|
||||
* `size` approximate size (log10)
|
||||
* `anonymous` true if anonymous, false if fxa
|
||||
* `event_properties`
|
||||
* `download_count` downloads completed
|
||||
* `ttl` time remaining before expiry truncated to hour
|
||||
* `agent` the browser name or first 6 characters of the user agent that made the request
|
||||
|
||||
### Client Events
|
||||
|
||||
Client events allow us to aggregate data about how the user interface is being used without tracking the lifecycle of individual files. In this context `user_id` and `user_properties` describe the user. The `user_id` and `device_id` change for all users at the beginning of each month.
|
||||
|
||||
* `session_id` timestamp
|
||||
* `user_id` hash of (fxa_id + Date.year + Date.month)
|
||||
* `device_id` hash of (localStorage random id + Date.year + Date.month)
|
||||
* `platform` [web | android]
|
||||
* `country`
|
||||
* `region`
|
||||
* `language`
|
||||
* `time` timestamp
|
||||
* `os_name`
|
||||
* `event_type` [client_visit | client_upload | client_download | client_delete | client_login | client_logout]
|
||||
* `event_properties`
|
||||
* `browser`
|
||||
* `browser_version`
|
||||
* `status` [ ok | error | cancel ]
|
||||
* Event specific properties (see below)
|
||||
* `user_properties`
|
||||
* `active_count` number of active uploads
|
||||
* `anonymous` true if anonymous, false if fxa
|
||||
* `experiments` list of experiment ids the user is participating in
|
||||
* `first_action` how this use came to Send the first time [ upload | download ]
|
||||
|
||||
#### Visit Event
|
||||
|
||||
* `entrypoint` [ upload | download ]
|
||||
|
||||
#### Upload Event
|
||||
|
||||
* `download_limit` download limit
|
||||
* `file_count` number of files
|
||||
* `password_protected` boolean
|
||||
* `size` approximate size (log10)
|
||||
* `time_limit` time limit
|
||||
* `duration` approximate transfer duration (log10)
|
||||
|
||||
#### Download Event
|
||||
|
||||
* `password_protected` boolean
|
||||
* `size` approximate size (log10)
|
||||
* `duration` approximate transfer duration (log10)
|
||||
|
||||
#### Delete Event
|
||||
|
||||
* `age` hours since uploaded
|
||||
* `downloaded` downloaded at least once
|
||||
|
||||
#### Login Event
|
||||
|
||||
* `trigger` [button | time | count | size]
|
||||
|
||||
#### Logout Event
|
||||
|
||||
* `trigger` [button | timeout]
|
||||
1822
package-lock.json
generated
1822
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "send",
|
||||
"description": "File Sharing Experiment",
|
||||
"version": "3.4.3",
|
||||
"version": "3.4.5",
|
||||
"author": "Mozilla (https://mozilla.org)",
|
||||
"contributors": [
|
||||
"Tim Visee <3a4fb3964f@sinenomine.email> (https://timvisee.com)"
|
||||
@@ -61,13 +61,13 @@
|
||||
"cache": true
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.16.3"
|
||||
"node": "^15.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.13",
|
||||
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
||||
"@babel/core": "^7.13.10",
|
||||
"@babel/plugin-proposal-class-properties": "^7.13.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/preset-env": "^7.12.13",
|
||||
"@babel/preset-env": "^7.13.10",
|
||||
"@dannycoates/webcrypto-liner": "^0.1.37",
|
||||
"@fullhuman/postcss-purgecss": "^1.3.0",
|
||||
"@mattiasbuelens/web-streams-polyfill": "0.2.1",
|
||||
@@ -78,7 +78,7 @@
|
||||
"base64-js": "^1.5.1",
|
||||
"content-disposition": "^0.5.3",
|
||||
"copy-webpack-plugin": "^5.1.2",
|
||||
"core-js": "^3.8.3",
|
||||
"core-js": "^3.9.1",
|
||||
"crc": "^3.8.0",
|
||||
"cross-env": "^6.0.3",
|
||||
"css-loader": "^3.6.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"lint-staged": "^9.4.2",
|
||||
"mocha": "^6.2.2",
|
||||
"morgan": "^1.9.1",
|
||||
"nanobus": "^4.4.0",
|
||||
"nanobus": "^4.5.0",
|
||||
"nanohtml": "^1.9.0",
|
||||
"nanotiming": "^7.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
@@ -113,16 +113,15 @@
|
||||
"proxyquire": "^2.1.3",
|
||||
"puppeteer": "^2.0.0",
|
||||
"raw-loader": "^3.1.0",
|
||||
"redis-mock": "^0.47.0",
|
||||
"rimraf": "^3.0.0",
|
||||
"script-loader": "^0.7.2",
|
||||
"sinon": "^7.5.0",
|
||||
"string-hash": "^1.1.3",
|
||||
"stylelint": "^13.9.0",
|
||||
"stylelint": "^13.12.0",
|
||||
"stylelint-config-standard": "^19.0.0",
|
||||
"stylelint-no-unsupported-browser-features": "^4.1.4",
|
||||
"svgo": "^1.3.2",
|
||||
"svgo-loader": "^2.2.1",
|
||||
"svgo-loader": "^2.2.2",
|
||||
"tailwindcss": "^1.9.6",
|
||||
"val-loader": "^1.1.1",
|
||||
"webpack": "4.38.0",
|
||||
@@ -136,9 +135,9 @@
|
||||
"@dannycoates/express-ws": "^5.0.3",
|
||||
"@fluent/bundle": "^0.13.0",
|
||||
"@fluent/langneg": "^0.3.0",
|
||||
"@google-cloud/storage": "^5.7.4",
|
||||
"@google-cloud/storage": "^5.8.1",
|
||||
"@sentry/node": "^5.30.0",
|
||||
"aws-sdk": "^2.838.0",
|
||||
"aws-sdk": "^2.864.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"choo": "^7.0.0",
|
||||
"cldr-core": "^35.1.0",
|
||||
@@ -150,8 +149,9 @@
|
||||
"mozlog": "^2.2.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"redis": "^2.8.0",
|
||||
"redis-mock": "^0.47.0",
|
||||
"selenium-standalone": "^6.23.0",
|
||||
"ua-parser-js": "^0.7.23"
|
||||
"ua-parser-js": "^0.7.24"
|
||||
},
|
||||
"availableLanguages": [
|
||||
"en-US",
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
const fetch = require('node-fetch');
|
||||
const config = require('./config');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
|
||||
function truncateToHour(timestamp) {
|
||||
return Math.floor(timestamp / HOUR) * HOUR;
|
||||
}
|
||||
|
||||
function orderOfMagnitude(n) {
|
||||
return Math.floor(Math.log10(n));
|
||||
}
|
||||
|
||||
function userId(fileId, ownerId) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(fileId);
|
||||
hash.update(ownerId);
|
||||
return hash.digest('hex').substring(32);
|
||||
}
|
||||
|
||||
function statUploadEvent(data) {
|
||||
const event = {
|
||||
session_id: -1,
|
||||
country: data.country,
|
||||
region: data.state,
|
||||
user_id: userId(data.id, data.owner),
|
||||
app_version: pkg.version,
|
||||
time: truncateToHour(Date.now()),
|
||||
event_type: 'server_upload',
|
||||
user_properties: {
|
||||
download_limit: data.dlimit,
|
||||
time_limit: data.timeLimit,
|
||||
size: orderOfMagnitude(data.size),
|
||||
anonymous: data.anonymous
|
||||
},
|
||||
event_properties: {
|
||||
agent: data.agent
|
||||
},
|
||||
event_id: 0
|
||||
};
|
||||
return sendBatch([event]);
|
||||
}
|
||||
|
||||
function statDownloadEvent(data) {
|
||||
const event = {
|
||||
session_id: -1,
|
||||
country: data.country,
|
||||
region: data.state,
|
||||
user_id: userId(data.id, data.owner),
|
||||
app_version: pkg.version,
|
||||
time: truncateToHour(Date.now()),
|
||||
event_type: 'server_download',
|
||||
event_properties: {
|
||||
agent: data.agent,
|
||||
download_count: data.download_count,
|
||||
ttl: data.ttl
|
||||
},
|
||||
event_id: data.download_count
|
||||
};
|
||||
return sendBatch([event]);
|
||||
}
|
||||
|
||||
function statDeleteEvent(data) {
|
||||
const event = {
|
||||
session_id: -1,
|
||||
country: data.country,
|
||||
region: data.state,
|
||||
user_id: userId(data.id, data.owner),
|
||||
app_version: pkg.version,
|
||||
time: truncateToHour(Date.now()),
|
||||
event_type: 'server_delete',
|
||||
event_properties: {
|
||||
agent: data.agent,
|
||||
download_count: data.download_count,
|
||||
ttl: data.ttl
|
||||
},
|
||||
event_id: data.download_count + 1
|
||||
};
|
||||
return sendBatch([event]);
|
||||
}
|
||||
|
||||
function clientEvent(
|
||||
event,
|
||||
ua,
|
||||
language,
|
||||
session_id,
|
||||
deltaT,
|
||||
platform,
|
||||
country,
|
||||
state
|
||||
) {
|
||||
const ep = event.event_properties || {};
|
||||
const up = event.user_properties || {};
|
||||
const event_properties = {
|
||||
browser: ua.browser.name,
|
||||
browser_version: ua.browser.version,
|
||||
status: ep.status,
|
||||
|
||||
age: ep.age,
|
||||
downloaded: ep.downloaded,
|
||||
download_limit: ep.download_limit,
|
||||
duration: ep.duration,
|
||||
entrypoint: ep.entrypoint,
|
||||
file_count: ep.file_count,
|
||||
password_protected: ep.password_protected,
|
||||
referrer: ep.referrer,
|
||||
size: ep.size,
|
||||
time_limit: ep.time_limit,
|
||||
trigger: ep.trigger,
|
||||
ttl: ep.ttl,
|
||||
utm_campaign: ep.utm_campaign,
|
||||
utm_content: ep.utm_content,
|
||||
utm_medium: ep.utm_medium,
|
||||
utm_source: ep.utm_source,
|
||||
utm_term: ep.utm_term,
|
||||
experiment: ep.experiment,
|
||||
variant: ep.variant
|
||||
};
|
||||
const user_properties = {
|
||||
active_count: up.active_count,
|
||||
anonymous: up.anonymous,
|
||||
experiments: up.experiments,
|
||||
first_action: up.first_action
|
||||
};
|
||||
return {
|
||||
app_version: pkg.version,
|
||||
country: country,
|
||||
device_id: event.device_id,
|
||||
event_properties,
|
||||
event_type: event.event_type,
|
||||
language,
|
||||
os_name: ua.os.name,
|
||||
os_version: ua.os.version,
|
||||
platform,
|
||||
region: state,
|
||||
session_id,
|
||||
time: event.time + deltaT,
|
||||
user_id: event.user_id,
|
||||
user_properties
|
||||
};
|
||||
}
|
||||
|
||||
async function sendBatch(events, timeout = 1000) {
|
||||
if (!config.amplitude_id) {
|
||||
return 200;
|
||||
}
|
||||
try {
|
||||
const result = await fetch('https://api.amplitude.com/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: config.amplitude_id,
|
||||
events
|
||||
}),
|
||||
timeout
|
||||
});
|
||||
return result.status;
|
||||
} catch (e) {
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
statUploadEvent,
|
||||
statDownloadEvent,
|
||||
statDeleteEvent,
|
||||
clientEvent,
|
||||
sendBatch
|
||||
};
|
||||
@@ -100,16 +100,6 @@ const conf = convict({
|
||||
arg: 'port',
|
||||
env: 'PORT'
|
||||
},
|
||||
amplitude_id: {
|
||||
format: String,
|
||||
default: '',
|
||||
env: 'AMPLITUDE_ID'
|
||||
},
|
||||
analytics_id: {
|
||||
format: String,
|
||||
default: '',
|
||||
env: 'GOOGLE_ANALYTICS_ID'
|
||||
},
|
||||
sentry_id: {
|
||||
format: String,
|
||||
default: '',
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
const storage = require('../storage');
|
||||
const { statDeleteEvent } = require('../amplitude');
|
||||
|
||||
module.exports = async function(req, res) {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const meta = req.meta;
|
||||
const ttl = await storage.ttl(id);
|
||||
await storage.del(id);
|
||||
res.sendStatus(200);
|
||||
statDeleteEvent({
|
||||
id,
|
||||
ip: req.ip,
|
||||
country: req.geo.country,
|
||||
state: req.geo.state,
|
||||
owner: meta.owner,
|
||||
download_count: meta.dl,
|
||||
ttl,
|
||||
agent: req.ua.browser.name || req.ua.ua.substring(0, 6)
|
||||
});
|
||||
} catch (e) {
|
||||
res.sendStatus(404);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const storage = require('../storage');
|
||||
const mozlog = require('../log');
|
||||
const log = mozlog('send.download');
|
||||
const { statDownloadEvent } = require('../amplitude');
|
||||
|
||||
module.exports = async function(req, res) {
|
||||
const id = req.params.id;
|
||||
@@ -27,17 +26,6 @@ module.exports = async function(req, res) {
|
||||
|
||||
const dl = meta.dl + 1;
|
||||
const dlimit = meta.dlimit;
|
||||
const ttl = await storage.ttl(id);
|
||||
statDownloadEvent({
|
||||
id,
|
||||
ip: req.ip,
|
||||
country: req.geo.country,
|
||||
state: req.geo.state,
|
||||
owner: meta.owner,
|
||||
download_count: dl,
|
||||
ttl,
|
||||
agent: req.ua.browser.name || req.ua.ua.substring(0, 6)
|
||||
});
|
||||
try {
|
||||
if (dl >= dlimit) {
|
||||
await storage.del(id);
|
||||
|
||||
@@ -112,7 +112,6 @@ module.exports = function(app) {
|
||||
require('./params')
|
||||
);
|
||||
app.post(`/api/info/:id${ID_REGEX}`, auth.owner, require('./info'));
|
||||
app.post('/api/metrics', require('./metrics'));
|
||||
app.get('/__version__', function(req, res) {
|
||||
// eslint-disable-next-line node/no-missing-require
|
||||
res.sendFile(require.resolve('../../dist/version.json'));
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
const { sendBatch, clientEvent } = require('../amplitude');
|
||||
|
||||
module.exports = async function(req, res) {
|
||||
try {
|
||||
const data = JSON.parse(req.body); // see http://crbug.com/490015
|
||||
const deltaT = Date.now() - data.now;
|
||||
const events = data.events.map(e =>
|
||||
clientEvent(
|
||||
e,
|
||||
req.ua,
|
||||
data.lang,
|
||||
data.session_id + deltaT,
|
||||
deltaT,
|
||||
data.platform,
|
||||
req.geo.country,
|
||||
req.geo.state
|
||||
)
|
||||
);
|
||||
const status = await sendBatch(events);
|
||||
res.sendStatus(status);
|
||||
} catch (e) {
|
||||
res.sendStatus(500);
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,6 @@ const config = require('../config');
|
||||
const mozlog = require('../log');
|
||||
const Limiter = require('../limiter');
|
||||
const fxa = require('../fxa');
|
||||
const { statUploadEvent } = require('../amplitude');
|
||||
const { encryptedSize } = require('../../app/utils');
|
||||
|
||||
const { Transform } = require('stream');
|
||||
@@ -108,18 +107,6 @@ module.exports = function(ws, req) {
|
||||
// in order to avoid having to check socket state and clean
|
||||
// up storage, possibly with an exception that we can catch.
|
||||
ws.send(JSON.stringify({ ok: true }));
|
||||
statUploadEvent({
|
||||
id: newId,
|
||||
ip: req.ip,
|
||||
country: req.geo.country,
|
||||
state: req.geo.state,
|
||||
owner,
|
||||
dlimit,
|
||||
timeLimit,
|
||||
anonymous: !user,
|
||||
size: limiter.length,
|
||||
agent: req.ua.browser.name || req.ua.ua.substring(0, 6)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('upload', e);
|
||||
|
||||
Reference in New Issue
Block a user