Implemented FxA
This commit is contained in:
parent
70bc2b7656
commit
718d74fa50
40 changed files with 1306 additions and 651 deletions
32
app/api.js
32
app/api.js
|
@ -65,14 +65,6 @@ export async function fileInfo(id, owner_token) {
|
|||
throw new Error(response.status);
|
||||
}
|
||||
|
||||
export async function hasPassword(id) {
|
||||
const response = await fetch(`/api/exists/${id}`);
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw new Error(response.status);
|
||||
}
|
||||
|
||||
export async function metadata(id, keychain) {
|
||||
const result = await fetchWithAuthAndRetry(
|
||||
`/api/metadata/${id}`,
|
||||
|
@ -141,6 +133,7 @@ async function upload(
|
|||
metadata,
|
||||
verifierB64,
|
||||
timeLimit,
|
||||
bearerToken,
|
||||
onprogress,
|
||||
canceller
|
||||
) {
|
||||
|
@ -159,6 +152,7 @@ async function upload(
|
|||
const fileMeta = {
|
||||
fileMetadata: metadataHeader,
|
||||
authorization: `send-v1 ${verifierB64}`,
|
||||
bearer: bearerToken,
|
||||
timeLimit
|
||||
};
|
||||
|
||||
|
@ -200,8 +194,9 @@ export function uploadWs(
|
|||
encrypted,
|
||||
metadata,
|
||||
verifierB64,
|
||||
onprogress,
|
||||
timeLimit
|
||||
timeLimit,
|
||||
bearerToken,
|
||||
onprogress
|
||||
) {
|
||||
const canceller = { cancelled: false };
|
||||
|
||||
|
@ -216,6 +211,7 @@ export function uploadWs(
|
|||
metadata,
|
||||
verifierB64,
|
||||
timeLimit,
|
||||
bearerToken,
|
||||
onprogress,
|
||||
canceller
|
||||
)
|
||||
|
@ -332,3 +328,19 @@ export function downloadFile(id, keychain, onprogress) {
|
|||
result: tryDownload(id, keychain, onprogress, canceller, 2)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFileList(bearerToken) {
|
||||
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
|
||||
const response = await fetch('/api/filelist', { headers });
|
||||
return response.body; // stream
|
||||
}
|
||||
|
||||
export async function setFileList(bearerToken, data) {
|
||||
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
|
||||
const response = await fetch('/api/filelist', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
body: data
|
||||
});
|
||||
return response.status === 200;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global MAXFILESIZE */
|
||||
/* global LIMITS */
|
||||
import { blobStream, concatStream } from './streams';
|
||||
|
||||
function isDupe(newFile, array) {
|
||||
|
@ -15,7 +15,7 @@ function isDupe(newFile, array) {
|
|||
}
|
||||
|
||||
export default class Archive {
|
||||
constructor(files) {
|
||||
constructor(files = []) {
|
||||
this.files = Array.from(files);
|
||||
}
|
||||
|
||||
|
@ -49,20 +49,19 @@ export default class Archive {
|
|||
return concatStream(this.files.map(file => blobStream(file)));
|
||||
}
|
||||
|
||||
addFiles(files) {
|
||||
addFiles(files, maxSize) {
|
||||
if (this.files.length + files.length > LIMITS.MAX_FILES_PER_ARCHIVE) {
|
||||
throw new Error('tooManyFiles');
|
||||
}
|
||||
const newFiles = files.filter(file => !isDupe(file, this.files));
|
||||
const newSize = newFiles.reduce((total, file) => total + file.size, 0);
|
||||
if (this.size + newSize > MAXFILESIZE) {
|
||||
return false;
|
||||
if (this.size + newSize > maxSize) {
|
||||
throw new Error('fileTooBig');
|
||||
}
|
||||
this.files = this.files.concat(newFiles);
|
||||
return true;
|
||||
}
|
||||
|
||||
checkSize() {
|
||||
return this.size <= MAXFILESIZE;
|
||||
}
|
||||
|
||||
remove(index) {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
/* global MAXFILESIZE */
|
||||
/* global DEFAULT_EXPIRE_SECONDS */
|
||||
/* global DEFAULTS LIMITS */
|
||||
import FileSender from './fileSender';
|
||||
import FileReceiver from './fileReceiver';
|
||||
import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
|
||||
import * as metrics from './metrics';
|
||||
import { hasPassword } from './api';
|
||||
import Archive from './archive';
|
||||
import { bytes } from './utils';
|
||||
import { prepareWrapKey } from './fxa';
|
||||
|
||||
export default function(state, emitter) {
|
||||
let lastRender = 0;
|
||||
|
@ -17,19 +16,8 @@ export default function(state, emitter) {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const changes = await state.user.syncFileList();
|
||||
const rerender = changes.incoming || changes.downloadCount;
|
||||
if (rerender) {
|
||||
render();
|
||||
}
|
||||
|
@ -57,6 +45,16 @@ export default function(state, emitter) {
|
|||
lastRender = Date.now();
|
||||
});
|
||||
|
||||
emitter.on('login', async () => {
|
||||
const k = await prepareWrapKey(state.storage);
|
||||
location.assign(`/api/fxa/login?keys_jwk=${k}`);
|
||||
});
|
||||
|
||||
emitter.on('logout', () => {
|
||||
state.user.logout();
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('changeLimit', async ({ file, value }) => {
|
||||
await file.changeLimit(value);
|
||||
state.storage.writeFile(file);
|
||||
|
@ -90,29 +88,37 @@ export default function(state, emitter) {
|
|||
});
|
||||
|
||||
emitter.on('addFiles', async ({ files }) => {
|
||||
if (state.archive) {
|
||||
if (!state.archive.addFiles(files)) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const archive = new Archive(files);
|
||||
if (!archive.checkSize()) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
|
||||
return;
|
||||
}
|
||||
state.archive = archive;
|
||||
const maxSize = state.user.maxSize;
|
||||
state.archive = state.archive || new Archive();
|
||||
try {
|
||||
state.archive.addFiles(files, maxSize);
|
||||
} catch (e) {
|
||||
alert(
|
||||
state.translate(e.message, {
|
||||
size: bytes(maxSize),
|
||||
count: LIMITS.MAX_FILES_PER_ARCHIVE
|
||||
})
|
||||
);
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
emitter.on('upload', async ({ type, dlCount, password }) => {
|
||||
if (!state.archive) return;
|
||||
if (state.storage.files.length >= LIMITS.MAX_ARCHIVES_PER_USER) {
|
||||
return alert(
|
||||
state.translate('tooManyArchives', {
|
||||
count: LIMITS.MAX_ARCHIVES_PER_USER
|
||||
})
|
||||
);
|
||||
}
|
||||
const size = state.archive.size;
|
||||
if (!state.timeLimit) state.timeLimit = DEFAULT_EXPIRE_SECONDS;
|
||||
const sender = new FileSender(state.archive, state.timeLimit);
|
||||
if (!state.timeLimit) state.timeLimit = DEFAULTS.EXPIRE_SECONDS;
|
||||
const sender = new FileSender(
|
||||
state.archive,
|
||||
state.timeLimit,
|
||||
state.user.bearerToken
|
||||
);
|
||||
|
||||
sender.on('progress', updateProgress);
|
||||
sender.on('encrypting', render);
|
||||
|
@ -132,7 +138,6 @@ export default function(state, emitter) {
|
|||
metrics.completedUpload(ownedFile);
|
||||
|
||||
state.storage.addFile(ownedFile);
|
||||
|
||||
if (password) {
|
||||
emitter.emit('password', { password, file: ownedFile });
|
||||
}
|
||||
|
@ -185,17 +190,6 @@ export default function(state, emitter) {
|
|||
render();
|
||||
});
|
||||
|
||||
emitter.on('getPasswordExist', async ({ id }) => {
|
||||
try {
|
||||
state.fileInfo = await hasPassword(id);
|
||||
render();
|
||||
} catch (e) {
|
||||
if (e.message === '404') {
|
||||
return emitter.emit('pushState', '/404');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
emitter.on('getMetadata', async () => {
|
||||
const file = state.fileInfo;
|
||||
|
||||
|
@ -204,7 +198,7 @@ export default function(state, emitter) {
|
|||
await receiver.getMetadata();
|
||||
state.transfer = receiver;
|
||||
} catch (e) {
|
||||
if (e.message === '401') {
|
||||
if (e.message === '401' || e.message === '404') {
|
||||
file.password = null;
|
||||
if (!file.requiresPassword) {
|
||||
return emitter.emit('pushState', '/404');
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Nanobus from 'nanobus';
|
||||
import Keychain from './keychain';
|
||||
import { delay, bytes } from './utils';
|
||||
import { delay, bytes, streamToArrayBuffer } from './utils';
|
||||
import { downloadFile, metadata } from './api';
|
||||
import { blobStream } from './streams';
|
||||
import Zip from './zip';
|
||||
|
@ -191,20 +191,6 @@ export default class FileReceiver extends Nanobus {
|
|||
}
|
||||
}
|
||||
|
||||
async function streamToArrayBuffer(stream, size) {
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
const reader = stream.getReader();
|
||||
let state = await reader.read();
|
||||
while (!state.done) {
|
||||
result.set(state.value, offset);
|
||||
offset += state.value.length;
|
||||
state = await reader.read();
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
async function saveFile(file) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
const dataView = new DataView(file.plaintext);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global DEFAULT_EXPIRE_SECONDS */
|
||||
/* global DEFAULTS */
|
||||
import Nanobus from 'nanobus';
|
||||
import OwnedFile from './ownedFile';
|
||||
import Keychain from './keychain';
|
||||
|
@ -7,9 +7,10 @@ import { uploadWs } from './api';
|
|||
import { encryptedSize } from './ece';
|
||||
|
||||
export default class FileSender extends Nanobus {
|
||||
constructor(file, timeLimit) {
|
||||
constructor(file, timeLimit, bearerToken) {
|
||||
super('FileSender');
|
||||
this.timeLimit = timeLimit || DEFAULT_EXPIRE_SECONDS;
|
||||
this.timeLimit = timeLimit || DEFAULTS.EXPIRE_SECONDS;
|
||||
this.bearerToken = bearerToken;
|
||||
this.file = file;
|
||||
this.keychain = new Keychain();
|
||||
this.reset();
|
||||
|
@ -75,11 +76,12 @@ export default class FileSender extends Nanobus {
|
|||
encStream,
|
||||
metadata,
|
||||
authKeyB64,
|
||||
this.timeLimit,
|
||||
this.bearerToken,
|
||||
p => {
|
||||
this.progress = [p, totalSize];
|
||||
this.emit('progress');
|
||||
},
|
||||
this.timeLimit
|
||||
}
|
||||
);
|
||||
|
||||
if (this.cancelled) {
|
||||
|
|
44
app/fxa.js
Normal file
44
app/fxa.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import jose from 'node-jose';
|
||||
import { arrayToB64, b64ToArray } from './utils';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export async function prepareWrapKey(storage) {
|
||||
const keystore = jose.JWK.createKeyStore();
|
||||
const keypair = await keystore.generate('EC', 'P-256');
|
||||
storage.set('fxaWrapKey', JSON.stringify(keystore.toJSON(true)));
|
||||
return jose.util.base64url.encode(JSON.stringify(keypair.toJSON()));
|
||||
}
|
||||
|
||||
export async function getFileListKey(storage, bundle) {
|
||||
const keystore = await jose.JWK.asKeyStore(
|
||||
JSON.parse(storage.get('fxaWrapKey'))
|
||||
);
|
||||
const result = await jose.JWE.createDecrypt(keystore).decrypt(bundle);
|
||||
const jwks = JSON.parse(jose.util.utf8.encode(result.plaintext));
|
||||
const jwk = jwks['https://identity.mozilla.com/apps/send'];
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
b64ToArray(jwk.k),
|
||||
{ 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));
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
/* global userInfo */
|
||||
import 'fast-text-encoding'; // MS Edge support
|
||||
import 'fluent-intl-polyfill';
|
||||
import app from './routes';
|
||||
|
@ -11,6 +12,8 @@ import metrics from './metrics';
|
|||
import experiments from './experiments';
|
||||
import Raven from 'raven-js';
|
||||
import './main.css';
|
||||
import User from './user';
|
||||
import { getFileListKey } from './fxa';
|
||||
|
||||
(async function start() {
|
||||
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
|
||||
|
@ -20,6 +23,9 @@ import './main.css';
|
|||
if (capa.streamDownload) {
|
||||
navigator.serviceWorker.register('/serviceWorker.js');
|
||||
}
|
||||
if (userInfo && userInfo.keys_jwe) {
|
||||
userInfo.fileListKey = await getFileListKey(storage, userInfo.keys_jwe);
|
||||
}
|
||||
app.use((state, emitter) => {
|
||||
state.capabilities = capa;
|
||||
state.transfer = null;
|
||||
|
@ -27,6 +33,7 @@ import './main.css';
|
|||
state.translate = locale.getTranslator();
|
||||
state.storage = storage;
|
||||
state.raven = Raven;
|
||||
state.user = new User(userInfo, storage);
|
||||
window.appState = state;
|
||||
let unsupportedReason = null;
|
||||
if (
|
||||
|
|
|
@ -22,6 +22,14 @@ export default class OwnedFile {
|
|||
this.timeLimit = obj.timeLimit;
|
||||
}
|
||||
|
||||
get hasPassword() {
|
||||
return !!this._hasPassword;
|
||||
}
|
||||
|
||||
get expired() {
|
||||
return this.dlimit === this.dtotal || Date.now() > this.expiresAt;
|
||||
}
|
||||
|
||||
async setPassword(password) {
|
||||
try {
|
||||
this.password = password;
|
||||
|
@ -48,11 +56,9 @@ export default class OwnedFile {
|
|||
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;
|
||||
|
@ -63,6 +69,7 @@ export default class OwnedFile {
|
|||
}
|
||||
// ignore other errors
|
||||
}
|
||||
return oldTotal !== this.dtotal || oldLimit !== this.dlimit;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
|
|
@ -4,7 +4,7 @@ const downloadButton = require('../../templates/downloadButton');
|
|||
const downloadedFiles = require('../../templates/uploadedFileList');
|
||||
|
||||
module.exports = function(state, emit) {
|
||||
const ownedFile = state.storage.getFileById(state.params.id);
|
||||
const fileInfo = state.fileInfo;
|
||||
|
||||
const trySendLink = html`
|
||||
<a class="link link--action" href="/">
|
||||
|
@ -25,7 +25,7 @@ module.exports = function(state, emit) {
|
|||
<div class="page">
|
||||
${titleSection(state)}
|
||||
|
||||
${downloadedFiles(ownedFile, state, emit)}
|
||||
${downloadedFiles(fileInfo, state, emit)}
|
||||
<div class="description">${state.translate('downloadMessage2')}</div>
|
||||
${downloadButton(state, emit)}
|
||||
|
||||
|
|
|
@ -62,11 +62,11 @@ module.exports = function(state, emit) {
|
|||
onfocus=${onfocus}
|
||||
onblur=${onblur}
|
||||
onchange=${addFiles} />
|
||||
|
||||
|
||||
</label>
|
||||
|
||||
<div class="uploadOptions ${optionClass}">
|
||||
${expireInfo(state)}
|
||||
${expireInfo(state, emit)}
|
||||
${setPasswordSection(state)}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
/* global downloadMetadata */
|
||||
const preview = require('../pages/preview');
|
||||
const password = require('../pages/password');
|
||||
|
||||
function createFileInfo(state) {
|
||||
return {
|
||||
id: state.params.id,
|
||||
secretKey: state.params.key,
|
||||
nonce: downloadMetadata.nonce,
|
||||
requiresPassword: downloadMetadata.pwd
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function(state, emit) {
|
||||
if (!state.fileInfo) {
|
||||
emit('getPasswordExist', { id: state.params.id });
|
||||
return;
|
||||
state.fileInfo = createFileInfo(state);
|
||||
}
|
||||
|
||||
state.fileInfo.id = state.params.id;
|
||||
state.fileInfo.secretKey = state.params.key;
|
||||
|
||||
if (!state.transfer && !state.fileInfo.requiresPassword) {
|
||||
emit('getMetadata');
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ function body(template) {
|
|||
<div class="stripedBox">
|
||||
<div class="mainContent">
|
||||
|
||||
${profile(state)}
|
||||
${profile(state, emit)}
|
||||
|
||||
${template(state, emit)}
|
||||
</div>
|
||||
|
@ -67,7 +67,11 @@ 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')));
|
||||
app.route('/signin', body(require('../pages/signin')));
|
||||
app.route('/api/fxa/oauth', function(state, emit) {
|
||||
emit('replaceState', '/');
|
||||
setTimeout(() => emit('render'));
|
||||
});
|
||||
app.route('*', body(require('../pages/notFound')));
|
||||
|
||||
module.exports = app;
|
||||
|
|
|
@ -38,7 +38,7 @@ class Storage {
|
|||
}
|
||||
|
||||
loadFiles() {
|
||||
const fs = [];
|
||||
const fs = new Map();
|
||||
for (let i = 0; i < this.engine.length; i++) {
|
||||
const k = this.engine.key(i);
|
||||
if (isFile(k)) {
|
||||
|
@ -48,14 +48,14 @@ class Storage {
|
|||
f.id = f.fileId;
|
||||
}
|
||||
|
||||
fs.push(f);
|
||||
fs.set(f.id, f);
|
||||
} catch (err) {
|
||||
// obviously you're not a golfer
|
||||
this.engine.removeItem(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs.sort((a, b) => a.createdAt - b.createdAt);
|
||||
return fs;
|
||||
}
|
||||
|
||||
get totalDownloads() {
|
||||
|
@ -90,26 +90,44 @@ class Storage {
|
|||
}
|
||||
|
||||
get files() {
|
||||
return this._files;
|
||||
return Array.from(this._files.values()).sort(
|
||||
(a, b) => a.createdAt - b.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
get user() {
|
||||
try {
|
||||
return JSON.parse(this.engine.getItem('user'));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
set user(info) {
|
||||
return this.engine.setItem('user', JSON.stringify(info));
|
||||
}
|
||||
|
||||
getFileById(id) {
|
||||
return this._files.find(f => f.id === id);
|
||||
return this._files.get(id);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.engine.getItem(id);
|
||||
}
|
||||
|
||||
set(id, value) {
|
||||
return this.engine.setItem(id, value);
|
||||
}
|
||||
|
||||
remove(property) {
|
||||
if (isFile(property)) {
|
||||
this._files.splice(this._files.findIndex(f => f.id === property), 1);
|
||||
this._files.delete(property);
|
||||
}
|
||||
this.engine.removeItem(property);
|
||||
}
|
||||
|
||||
addFile(file) {
|
||||
this._files.push(file);
|
||||
this._files.set(file.id, file);
|
||||
this.writeFile(file);
|
||||
}
|
||||
|
||||
|
@ -120,6 +138,39 @@ class Storage {
|
|||
writeFiles() {
|
||||
this._files.forEach(f => this.writeFile(f));
|
||||
}
|
||||
|
||||
clearLocalFiles() {
|
||||
this._files.forEach(f => this.engine.removeItem(f.id));
|
||||
this._files = new Map();
|
||||
}
|
||||
|
||||
async merge(files = []) {
|
||||
let incoming = false;
|
||||
let outgoing = false;
|
||||
let downloadCount = false;
|
||||
for (const f of files) {
|
||||
if (!this.getFileById(f.id)) {
|
||||
this.addFile(new OwnedFile(f));
|
||||
incoming = true;
|
||||
}
|
||||
}
|
||||
const workingFiles = this.files.slice();
|
||||
for (const f of workingFiles) {
|
||||
const cc = await f.updateDownloadCount();
|
||||
downloadCount = downloadCount || cc;
|
||||
outgoing = outgoing || f.expired;
|
||||
if (f.expired) {
|
||||
this.remove(f.id);
|
||||
} else if (!files.find(x => x.id === f.id)) {
|
||||
outgoing = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
incoming,
|
||||
outgoing,
|
||||
downloadCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Storage();
|
||||
|
|
|
@ -3,7 +3,7 @@ const raw = require('choo/html/raw');
|
|||
const selectbox = require('../selectbox');
|
||||
const timeLimitText = require('../timeLimitText');
|
||||
|
||||
module.exports = function(state) {
|
||||
module.exports = function(state, emit) {
|
||||
const el = html`<div> ${raw(
|
||||
state.translate('frontPageExpireInfo', {
|
||||
downloadCount: '<select id=dlCount></select>',
|
||||
|
@ -11,15 +11,25 @@ module.exports = function(state) {
|
|||
})
|
||||
)}
|
||||
</div>`;
|
||||
if (el.__encoded) {
|
||||
// we're rendering on the server
|
||||
return el;
|
||||
}
|
||||
|
||||
const dlCountSelect = el.querySelector('#dlCount');
|
||||
el.replaceChild(
|
||||
selectbox(
|
||||
state.downloadCount || 1,
|
||||
[1, 2, 3, 4, 5, 20],
|
||||
[1, 2, 3, 4, 5, 20, 50, 100, 200],
|
||||
num => state.translate('downloadCount', { num }),
|
||||
value => {
|
||||
const max = state.user.maxDownloads;
|
||||
if (value > max) {
|
||||
alert('todo: this setting requires an account');
|
||||
value = max;
|
||||
}
|
||||
state.downloadCount = value;
|
||||
emit('render');
|
||||
}
|
||||
),
|
||||
dlCountSelect
|
||||
|
@ -29,10 +39,16 @@ module.exports = function(state) {
|
|||
el.replaceChild(
|
||||
selectbox(
|
||||
state.timeLimit || 86400,
|
||||
[300, 3600, 86400, 604800, 1209600],
|
||||
[300, 3600, 86400, 604800],
|
||||
num => timeLimitText(state.translate, num),
|
||||
value => {
|
||||
const max = state.user.maxExpireSeconds;
|
||||
if (value > max) {
|
||||
alert('todo: this setting requires an account');
|
||||
value = max;
|
||||
}
|
||||
state.timeLimit = value;
|
||||
emit('render');
|
||||
}
|
||||
),
|
||||
timeSelect
|
||||
|
|
|
@ -1,33 +1,41 @@
|
|||
const html = require('choo/html');
|
||||
const assets = require('../../../common/assets');
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = function(state) {
|
||||
const notLoggedInMenu = html`
|
||||
module.exports = function(state, emit) {
|
||||
const user = state.user;
|
||||
const menu = user.loggedIn
|
||||
? html`
|
||||
<ul class="account_dropdown">
|
||||
<li class="account_dropdown__text">
|
||||
${user.email}
|
||||
</li>
|
||||
<li>
|
||||
<a class="account_dropdown__link" onclick=${logout}>${state.translate(
|
||||
'logOut'
|
||||
)}</a>
|
||||
</li>
|
||||
</ul>`
|
||||
: html`
|
||||
<ul class="account_dropdown"
|
||||
tabindex="-1"
|
||||
>
|
||||
<li>
|
||||
<a class=account_dropdown__link>${state.translate(
|
||||
'accountMenuOption'
|
||||
)}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/signin"
|
||||
class=account_dropdown__link>${state.translate(
|
||||
'signInMenuOption'
|
||||
)}</a>
|
||||
<a class="account_dropdown__link" onclick=${login}>${state.translate(
|
||||
'signInMenuOption'
|
||||
)}</a>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="account">
|
||||
<img
|
||||
src="${assets.get('user.svg')}"
|
||||
onclick=${avatarClick}
|
||||
alt="account"/>
|
||||
${notLoggedInMenu}
|
||||
<div class="account__avatar">
|
||||
<img
|
||||
class="account__avatar"
|
||||
src="${user.avatar}"
|
||||
onclick=${avatarClick}
|
||||
/>
|
||||
</div>
|
||||
${menu}
|
||||
</div>`;
|
||||
|
||||
function avatarClick(event) {
|
||||
|
@ -37,6 +45,16 @@ module.exports = function(state) {
|
|||
dropdown.focus();
|
||||
}
|
||||
|
||||
function login(event) {
|
||||
event.preventDefault();
|
||||
emit('login');
|
||||
}
|
||||
|
||||
function logout(event) {
|
||||
event.preventDefault();
|
||||
emit('logout');
|
||||
}
|
||||
|
||||
//the onblur trick makes links unclickable wtf
|
||||
/*
|
||||
function hideMenu(event) {
|
||||
|
|
|
@ -5,12 +5,18 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.account__avatar {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.account_dropdown {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: -15px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
list-style-type: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
|
@ -62,3 +68,11 @@
|
|||
background-color: var(--primaryControlBGColor);
|
||||
color: var(--primaryControlFGColor);
|
||||
}
|
||||
|
||||
.account_dropdown__text {
|
||||
display: block;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--lightTextColor);
|
||||
line-height: 24px;
|
||||
}
|
||||
|
|
102
app/user.js
Normal file
102
app/user.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
/* global LIMITS */
|
||||
import assets from '../common/assets';
|
||||
import { getFileList, setFileList } from './api';
|
||||
import { encryptStream, decryptStream } from './ece';
|
||||
import { b64ToArray, streamToArrayBuffer } from './utils';
|
||||
import { blobStream } from './streams';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export default class User {
|
||||
constructor(info, storage) {
|
||||
if (info && storage) {
|
||||
storage.user = info;
|
||||
}
|
||||
this.storage = storage;
|
||||
this.data = info || storage.user || {};
|
||||
}
|
||||
|
||||
get avatar() {
|
||||
const defaultAvatar = assets.get('user.svg');
|
||||
if (this.data.avatarDefault) {
|
||||
return defaultAvatar;
|
||||
}
|
||||
return this.data.avatar || defaultAvatar;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.data.displayName;
|
||||
}
|
||||
|
||||
get email() {
|
||||
return this.data.email;
|
||||
}
|
||||
|
||||
get loggedIn() {
|
||||
return !!this.data.access_token;
|
||||
}
|
||||
|
||||
get bearerToken() {
|
||||
return this.data.access_token;
|
||||
}
|
||||
|
||||
get maxSize() {
|
||||
return this.loggedIn ? LIMITS.MAX_FILE_SIZE : LIMITS.ANON.MAX_FILE_SIZE;
|
||||
}
|
||||
|
||||
get maxExpireSeconds() {
|
||||
return this.loggedIn
|
||||
? LIMITS.MAX_EXPIRE_SECONDS
|
||||
: LIMITS.ANON.MAX_EXPIRE_SECONDS;
|
||||
}
|
||||
|
||||
get maxDownloads() {
|
||||
return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS;
|
||||
}
|
||||
|
||||
login() {}
|
||||
|
||||
logout() {
|
||||
this.storage.user = null;
|
||||
this.storage.clearLocalFiles();
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
async syncFileList() {
|
||||
let changes = { incoming: false, outgoing: false, downloadCount: false };
|
||||
if (!this.loggedIn) {
|
||||
return this.storage.merge();
|
||||
}
|
||||
let list = [];
|
||||
try {
|
||||
const encrypted = await getFileList(this.bearerToken);
|
||||
const decrypted = await streamToArrayBuffer(
|
||||
decryptStream(encrypted, b64ToArray(this.data.fileListKey))
|
||||
);
|
||||
list = JSON.parse(textDecoder.decode(decrypted));
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
changes = await this.storage.merge(list);
|
||||
if (!changes.outgoing) {
|
||||
return changes;
|
||||
}
|
||||
try {
|
||||
const blob = new Blob([
|
||||
textEncoder.encode(JSON.stringify(this.storage.files))
|
||||
]);
|
||||
const encrypted = await streamToArrayBuffer(
|
||||
encryptStream(blobStream(blob), b64ToArray(this.data.fileListKey))
|
||||
);
|
||||
await setFileList(this.bearerToken, encrypted);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
34
app/utils.js
34
app/utils.js
|
@ -151,6 +151,37 @@ function browserName() {
|
|||
}
|
||||
}
|
||||
|
||||
async function streamToArrayBuffer(stream, size) {
|
||||
const reader = stream.getReader();
|
||||
let state = await reader.read();
|
||||
|
||||
if (size) {
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
while (!state.done) {
|
||||
result.set(state.value, offset);
|
||||
offset += state.value.length;
|
||||
state = await reader.read();
|
||||
}
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let len = 0;
|
||||
while (!state.done) {
|
||||
parts.push(state.value);
|
||||
len += state.value.length;
|
||||
state = await reader.read();
|
||||
}
|
||||
let offset = 0;
|
||||
const result = new Uint8Array(len);
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fadeOut,
|
||||
delay,
|
||||
|
@ -164,5 +195,6 @@ module.exports = {
|
|||
loadShim,
|
||||
isFile,
|
||||
openLinksInNewTab,
|
||||
browserName
|
||||
browserName,
|
||||
streamToArrayBuffer
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue