This commit is contained in:
Christoph Wiechert
2017-04-23 13:41:29 +02:00
commit 30d6918b83
42 changed files with 2018 additions and 0 deletions

15
app/.babelrc Normal file
View File

@@ -0,0 +1,15 @@
{
"presets": [
[
"env",
{
"modules": false
}
],
"stage-2"
],
"plugins": [
"transform-runtime"
],
"comments": false
}

4
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
node_modules/
dist/
npm-debug.log

14
app/README.md Normal file
View File

@@ -0,0 +1,14 @@
# PsiTransfer Upload / Download App
## Build Setup
``` bash
# install dependencies
npm install
# serve with hot reload at localhost:8080
npm run dev
# build for production with minification
npm run build
```

35
app/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "psitransfer",
"description": "A Vue.js project",
"version": "1.0.0",
"author": "Christoph Wiechert <wio@psitrax.de>",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --inline --hot --host 0.0.0.0",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
},
"dependencies": {
"babel-polyfill": "^6.23.0",
"crypto-js": "^3.1.9-1",
"drag-drop": "^2.13.2",
"tus-js-client": "^1.4.3",
"uuid": "^3.0.1",
"vue": "^2.2.6",
"vuex": "^2.3.1"
},
"devDependencies": {
"babel-core": "^6.24.1",
"babel-loader": "^6.4.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0",
"cross-env": "^4.0.0",
"css-loader": "^0.28.0",
"file-loader": "^0.11.1",
"pug": "^2.0.0-beta.12",
"vue-loader": "^11.3.4",
"vue-template-compiler": "^2.2.6",
"webpack": "^2.4.1",
"webpack-dev-server": "^2.4.2"
}
}

140
app/src/Download.vue Normal file
View File

@@ -0,0 +1,140 @@
<template lang="pug">
.download-app
.btn-group(style='position: absolute; top: 15px; right: 15px;')
a.btn.btn-sm.btn-info(@click='newSession()', title='New Upload')
i.fa.fa-fw.fa-cloud-upload
| new upload
.alert.alert-danger(v-show="error")
strong
i.fa.fa-exclamation-triangle
| {{ error }}
.well(v-if='needsPassword')
h3(style='margin-top: 0') Password
.form-group
input.form-control(type='password', v-model='password')
p.text-danger(v-show='passwordWrong')
strong Access denied!
|
button.btn.btn-primary(:disabled='password.length<1', @click='decrypt()')
i.fa.fa-key
| decrypt
.panel.panel-primary(v-if='!needsPassword')
.panel-heading Files
.panel-body
table.table.table-hover.table-striped(style='margin-bottom: 0')
tbody
tr(v-for='file in files', style='cursor: pointer', @click='download(file)')
td(style='width: 60px')
file-icon(:file='file')
td
p
clipboard.pull-right(:value='host + file.url', @change='copied(file, $event)', title='Copy to clipboard', style='margin-left: 5px')
a
i.fa.fa-fw.fa-copy
i.fa.fa-check.text-success.pull-right(v-show='file.downloaded')
|
strong {{ file.metadata.name }}
|
small ({{ humanFileSize(file.size) }})
p {{ file.metadata.comment }}
</template>
<script>
"use strict";
import AES from 'crypto-js/aes';
import encUtf8 from 'crypto-js/enc-utf8';
import FileIcon from './common/FileIcon.vue';
import Clipboard from './common/Clipboard.vue';
export default {
name: 'app',
components: { FileIcon, Clipboard },
data () {
return {
files: [],
sid: document.location.pathname.substr(1),
passwordWrong: false,
needsPassword: false,
password: '',
content: '',
error: '',
host: document.location.protocol + '//' + document.location.host
}
},
methods: {
download(file) {
if(file.downloaded && file.metadata.retention === 'one-time') {
alert('One-Time Download: File is not available anymore.');
return;
}
document.location.href = file.url;
file.downloaded = true;
},
copied(file, $event) {
file.downloaded = $event === 'copied';
},
decrypt() {
this.passwordWrong = false;
this.files = this.files.map(item => {
if(typeof item === 'object') return item;
const d = AES.decrypt(item, this.password);
try {
return Object.assign(
JSON.parse(d.toString(encUtf8)),
{downloaded: false}
);
} catch(e) {
this.passwordWrong = true;
return item;
}
});
if(!this.passwordWrong) {
this.needsPassword = false;
this.password = '';
}
},
humanFileSize(fileSizeInBytes) {
let i = -1;
const byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB'];
do {
fileSizeInBytes = fileSizeInBytes / 1024;
i++;
}
while(fileSizeInBytes > 1024);
return Math.max(fileSizeInBytes, 0.01).toFixed(2) + byteUnits[i];
},
newSession() {
document.location.href = '/';
}
},
beforeMount() {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/' + this.sid + '.json');
xhr.onload = () => {
if(xhr.status === 200) {
try {
this.files = JSON.parse(xhr.responseText).map(f => {
if(typeof f !== 'object') {
this.needsPassword = true;
return f;
}
return Object.assign(f, {downloaded: false});
});
} catch(e) {
this.error = e.toString();
}
} else {
this.error = `${xhr.status} ${xhr.statusText}: ${xhr.responseText}`;
}
};
xhr.send();
}
}
</script>

80
app/src/Upload.vue Normal file
View File

@@ -0,0 +1,80 @@
<template lang="pug">
.upload-app
.btn-group(style='position: absolute; top: 15px; right: 15px;')
a.btn.btn-sm.btn-info(@click='newSession()', title='New Upload')
i.fa.fa-fw.fa-cloud-upload
| new upload
.alert.alert-danger(v-show="error")
strong
i.fa.fa-exclamation-triangle
| {{ error }}
.well(v-show="state === 'uploaded'")
.pull-right
a.btn.btn-primary(style="margin-right: 5px", :href="mailLnk")
i.fa.fa-fw.fa-envelope
| Mail
clipboard.btn.btn-primary(:value='shareUrl')
h3.text-success(style='margin-top: 0')
i.fa.fa-check
| Upload completed
div(style='margin-top: 1rem; padding-bottom: 0')
strong(style="margin-right: 5px") Download Link:
|
a(:href='shareUrl') {{ shareUrl }}
.row(style="margin-bottom:10px", v-show="state === 'uploading'")
.col-xs-12
i.fa.fa-spinner.fa-spin.fa-2x.fa-fw.pull-left
.progress(style="height:25px")
.progress-bar.progress-bar-success.progress-bar-striped.active(:style="{width: percentUploaded+'%'}", style="line-height:25px")
span(v-show='percentUploaded>10') {{ percentUploaded }}%
.row
.col-sm-7
files(:value="[]")
.col-sm-5
settings
.text-right(v-show='files.length && !disabled')
button.btn.btn-lg.btn-success(@click="$store.dispatch('upload/upload')")
i.fa.fa-upload
| upload
.text-right(v-show="state === 'uploadError'")
button.btn.btn-lg.btn-success(@click="$store.dispatch('upload/upload')")
i.fa.fa-upload
| retry
</template>
<script type="text/babel">
"use strict";
import {mapState, mapGetters} from 'vuex';
import Settings from './Upload/Settings.vue';
import Files from './Upload/Files.vue';
import Clipboard from './common/Clipboard.vue'
export default {
name: 'App',
components: {
Settings,
Files,
Clipboard
},
computed: {
...mapState(['error', 'disabled', 'state']),
...mapState('upload', ['sid', 'files']),
...mapGetters('upload', ['percentUploaded', 'shareUrl']),
mailLnk: function(){
return this.$store.state.config
&& this.$store.state.config.mailTemplate
&& this.$store.state.config.mailTemplate.replace('%%URL%%', this.shareUrl);
}
},
methods: {
newSession() {
if(!confirm('Create a new upload session?')) return;
document.location.reload();
}
}
}
</script>

74
app/src/Upload/Files.vue Normal file
View File

@@ -0,0 +1,74 @@
<template lang="pug">
div
.panel.panel-default(:class="{'panel-primary': !disabled}")
.panel-heading Files
.panel-body
.dropHint(:style="{cursor: disabled ? 'default' : 'pointer'}",
onclick="document.getElementById('fileInput').click();",
v-show="files.length === 0")
i.fa.fa-plus.fa-4x
br
| Drop your files here
table.table.table-striped
tbody
tr(v-for="file in files")
td(style="width: 60px")
file-icon(:file="file._File")
td
p
strong {{ file.name }}
small ({{ file.humanSize }})
p
input.form-control.input-sm(type="text", placeholder="comment...", v-model="file.comment", :disabled="disabled")
.alert.alert-danger(v-if="file.error")
i.fa.fa-fw.fa-exclamation-triangle
| {{ file.error }}
.progress(v-show="!file.error && (state === 'uploading' || state === 'uploaded')", style="height:7px")
.progress-bar.progress-bar-success.progress-bar-striped(:style="{width: file.progress.percentage+'%'}",:class="{active:!file.uploaded}")
td(style="width: 33px;")
a.btn.btn-warning.btn-sm(@click="!disabled && $store.commit('upload/REMOVE_FILE', file)", :disabled="disabled")
i.fa.fa-trash.pull-right(style="display: inline-block; margin-left: auto; margin-right: auto;")
input#fileInput(type="file", @change="$store.dispatch('upload/addFiles', $event.target.files)", multiple="", :disabled="disabled", style="display: none")
.text-right
a.btn.btn-success.btn-sm(onclick="document.getElementById('fileInput').click();", :disabled="disabled", v-show="files.length>0")
i.fa.fa-plus-circle.fa-fw
</template>
<script type="text/babel">
"use strict";
import {mapState} from 'vuex';
import dragDrop from 'drag-drop';
import FileIcon from '../common/FileIcon.vue';
export default {
name: 'Files',
components: { FileIcon },
computed: mapState({
disabled: 'disabled',
state: 'state',
files: state => state.upload.files
}),
mounted() {
// init drop files support on <body>
dragDrop('body', files => this.$store.dispatch('upload/addFiles', files));
}
};
</script>
<style>
.dropHint {
text-align: center;
padding: 19px 0;
}
.dropHint i {
color: #337AB7;
}
.dropHint:hover i {
color: #286090;
}
</style>

View File

@@ -0,0 +1,63 @@
<template lang="pug">
div(v-if="config && config.retentions")
.panel.panel-default(:class="{'panel-info': !disabled}")
.panel-heading Settings
.panel-body
.form-group
label(for='retention') Retention
|
select#retention.form-control(:value='retention', :disabled='disabled',
@input="$store.commit('upload/RETENTION', $event.target.value)")
option(v-for='(label, seconds, index) in config.retentions',
:value="seconds", :selected="seconds == retention") {{ label }}
div
label(for='password') Password
.input-group
input#password.form-control(type='text', :value='password',
@input="$store.commit('upload/PASSWORD', $event.target.value)",
:disabled='disabled', placeholder='optional')
span.input-group-addon(style='cursor: pointer', title='generate password', @click='generatePassword()')
i.fa.fa-key
</template>
<script type="text/babel">
"use strict";
import {mapState} from 'vuex';
const passGen = {
_pattern : /[A-Z0-9_\-\+\!]/,
_getRandomByte: function() {
const result = new Uint8Array(1);
window.crypto.getRandomValues(result);
return result[0];
},
generate: function(length) {
if(!window.crypto || !window.crypto.getRandomValues) return '';
return Array.apply(null, {'length': length}).map(function() {
let result;
while(true) {
result = String.fromCharCode(this._getRandomByte());
if(this._pattern.test(result)) return result;
}
}, this).join('');
}
};
export default {
name: 'Settings',
computed: mapState({
config: 'config',
disabled: 'disabled',
retention: state => state.upload.retention,
password: state => state.upload.password,
}),
methods: {
generatePassword() {
if(this.disabled) return;
this.$store.commit('upload/PASSWORD', passGen.generate(10));
}
}
};
</script>

41
app/src/Upload/store.js Normal file
View File

@@ -0,0 +1,41 @@
'use strict';
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
import config from './store/config.js';
import upload from './store/upload.js';
export default new Vuex.Store({
modules: {
config,
upload
},
state: {
error: '',
// disable all input fields
disabled: false,
/* States:
* new: can modify settings and add/remove files
* uploading: probably let user pause/cancel upload
* uploaded: show download link
* uploadError: show retry btn */
state: 'new'
},
mutations: {
ERROR(state, msg) {
state.error = msg;
state.disabled = true;
},
DISABLE(state) {
state.disabled = true;
},
STATE(state, val) {
state.state = val;
if(val !== 'new') state.disabled = true;
},
},
});

View File

@@ -0,0 +1,39 @@
'use strict';
import Vue from 'vue';
export default {
namespaced: true,
state: {},
mutations: {
SET(state, val) {
for (let k in val) {
Vue.set(state, k, val[k]);
}
}
},
actions: {
fetch({commit, }) {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/config.json');
xhr.onload = () => {
if(xhr.status === 200) {
try {
const conf = JSON.parse(xhr.responseText);
commit('SET', conf);
commit('upload/RETENTION', conf.defaultRetention, {root:true});
}
catch(e) {
commit('ERROR', `Config parse Error: ${e.message}`, {root: true});
}
}
else {
commit('ERROR', `Config load error: ${xhr.status} ${xhr.statusText}`, {root: true});
}
};
xhr.send();
}
}
}

View File

@@ -0,0 +1,184 @@
'use strict';
import tus from "tus-js-client";
import uuid from 'uuid/v4';
import md5 from 'crypto-js/md5';
function humanFileSize(fileSizeInBytes) {
let i = -1;
const byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB'];
do {
fileSizeInBytes = fileSizeInBytes / 1024;
i++;
}
while(fileSizeInBytes > 1024);
return Math.max(fileSizeInBytes, 0.01).toFixed(2) + byteUnits[i];
}
let onOnlineHandler = null;
let onOnlineHandlerAttached = false;
export default {
namespaced: true,
state: {
retention: null,
password: '',
files: [],
sid: md5(uuid()).toString().substr(0, 12),
bytesUploaded: 0,
},
getters: {
shareUrl: state => {
return document.location.protocol + '//' + document.location.host + '/' + state.sid;
},
percentUploaded: state => {
return Math.min(
Math.round(state.files.reduce((sum, file) => sum += file.progress.percentage, 0) / state.files.length), 100);
}
},
mutations: {
RETENTION(state, seconds) {
state.retention = seconds;
},
PASSWORD(state, pwd) {
state.password = pwd;
},
ADD_FILE(state, file) {
state.files.splice(0, 0, file);
},
REMOVE_FILE(state, file) {
let index = state.files.indexOf(file);
if(index > -1) state.files.splice(index, 1);
},
UPDATE_FILE(state, payload) {
for(let k in payload.data) {
payload.file[k] = payload.data[k];
}
},
NEW_SESSION(state) {
state.password = '';
state.files.splice(0, state.files.length);
state.sid = md5(uuid()).toString().substr(0, 12);
}
},
actions: {
addFiles({commit, state}, files) {
if(state.disabled) return;
for(let i = 0; i < files.length; i++) {
// wrap, don't change the HTML5-File-API object
commit('ADD_FILE', {
_File: files[i],
name: files[i].name,
comment: '',
progress: {percentage: 0, humanSize: 0},
uploaded: false,
error: false,
humanSize: humanFileSize(files[i].size),
_retryDelay: 500,
_retries: 0
});
}
},
upload({commit, dispatch, state}) {
commit('STATE', 'uploading', {root:true});
commit('ERROR', '', {root:true});
if(onOnlineHandler === null) {
onOnlineHandler = function() {
onOnlineHandlerAttached = false;
commit('ERROR', false, {root: true});
dispatch('upload');
}
}
if(onOnlineHandlerAttached) window.removeEventListener('online', onOnlineHandler);
// upload all files in parallel
state.files.forEach(file => {
file.error = '';
file._retries = 0;
file._retryDelay = 500;
const _File = file._File;
let tusUploader = new tus.Upload(_File, {
metadata: {
sid: state.sid,
retention: state.retention,
password: state.password,
name: file.name,
comment: file.comment,
type: file._File.type
},
resume: true,
endpoint: "/files/",
fingerprint: (file) => {
// include sid to prevent duplicate file detection on different session
return ["tus", state.sid, file.name, file.type, file.size, file.lastModified].join("-");
},
retryDelays: null,
onError(error) {
// browser is offline
if(!navigator.onLine) {
commit('ERROR', 'You are offline. Your uploads will resume as soon as you are back online.', {root: true});
if(!onOnlineHandlerAttached) {
onOnlineHandlerAttached = true;
// attach onOnline handler
window.addEventListener('online', onOnlineHandler);
}
}
// Client Error
else if(error && error.originalRequest &&
error.originalRequest.status >= 400 && error.originalRequest.status < 500)
{
commit('UPDATE_FILE', {file, data: {error: error.message || error.toString()}});
}
// Generic Error
else {
if(file._retries > 30) {
commit('UPDATE_FILE', {file, data: {error: error.message || error.toString()}});
if(state.files.every(f => f.error)) {
commit('STATE', 'uploadError', {root: true});
commit('ERROR', 'Upload failed.', {root: true});
}
return;
}
file._retryDelay = Math.min(file._retryDelay*1.7, 10000);
file._retries++;
if(console) console.log(error.message || error.toString(), '; will retry in', file._retryDelay, 'ms');
setTimeout(() => tusUploader.start(), file._retryDelay);
}
},
onProgress(bytesUploaded, bytesTotal) {
// uploaded=total gets also emitted on error
if(bytesUploaded === bytesTotal) return;
file.error = '';
file._retries = 0;
file._retryDelay = 500;
const percentage = Math.round(bytesUploaded / bytesTotal * 10000) / 100;
commit('UPDATE_FILE', {
file,
data: {progress: {percentage, humanSize: humanFileSize(bytesUploaded)}}
});
},
onSuccess() {
localStorage.removeItem(tusUploader._fingerprint);
commit('UPDATE_FILE', {file, data: {
uploaded:true,
progress: {percentage: 100, humanFileSize: file.humanSize}
}});
if(state.files.every(f => f.uploaded)) commit('STATE', 'uploaded', {root: true});
}
});
tusUploader.start();
});
}
}
}

View File

@@ -0,0 +1,62 @@
<!--
# Clipboard Component
Copies a string into the clipboard
## Props
* value: String
## Slots
* default: Replaces inner content
* text: Replaces the text
-->
<template lang="pug">
span(@click.stop='copy()', style='cursor: pointer')
slot(:state='state')
i.fa.fa-fw(:class="{'fa-copy':state=='pristine','fa-check':state=='copied','fa-exclamation-triangle':state=='error'}")
slot(name='text') Copy
</template>
<script type="text/babel">
"use strict";
export default {
name: "Clipboard",
props: {
value: {
type: String,
required: true
}
},
data() {
return {
state: 'pristine' // copied, error
};
},
methods: {
copy() {
let el = document.createElement('textarea');
Object.assign(el.style, {
position: 'absolute',
left: '-200%'
});
el.value = this.value;
document.body.appendChild(el);
let success = false;
try {
el.select();
success = document.execCommand('copy');
}
catch(e) {
alert('Dein alter Browser unterstützt diese Funktion leider nicht.');
console.error(e);
}
document.body.removeChild(el);
this.state = success ? 'copied' : 'error';
this.$emit('change', this.state);
}
}
};
</script>

View File

@@ -0,0 +1,37 @@
<template lang="pug">
div
i.fa.fa-fw.fa-3x(v-if='!isImageBlob', :class='iconClass')
|
img(v-if='isImageBlob', :src='blobUrl', style='width:54px; height:auto;')
</template>
<script type="text/babel">
"use strict";
export default {
props: ['file'],
computed: {
iconClass() {
const type = this.file.type || this.file.metadata && this.file.metadata.type;
if(!type) return 'fa-file-o';
if(type.startsWith('image')) return 'fa-file-image-o';
if(type.startsWith('text')) return 'fa-file-text-o';
if(type.startsWith('video')) return 'fa-file-video-o';
if(type.startsWith('audio')) return 'fa-file-audio-o';
if(type === 'application/pdf') return 'fa-file-pdf-o';
if(type.startsWith('application')) return 'fa-file-archive-o';
return 'fa-file-o';
},
isImageBlob() {
if(!URL && !webkitURL) return false;
return this.file instanceof File && this.file.type.startsWith('image');
},
blobUrl() {
if(!this.isImageBlob) return;
return (URL || webkitURL).createObjectURL(this.file);
}
}
};
</script>

10
app/src/download.js Normal file
View File

@@ -0,0 +1,10 @@
import 'babel-polyfill';
import Vue from 'vue';
import Download from './Download.vue';
new Vue({
el: '#download',
render: h => h(Download)
});
window.PSITRANSFER_VERSION = PSITRANSFER_VERSION;

17
app/src/upload.js Normal file
View File

@@ -0,0 +1,17 @@
"use strict";
import 'babel-polyfill';
import Vue from 'vue';
import Upload from './Upload.vue';
import store from './Upload/store.js';
new Vue({
el: '#upload',
store,
render: h => h(Upload),
beforeCreate() {
this.$store.dispatch('config/fetch');
}
});
window.PSITRANSFER_VERSION = PSITRANSFER_VERSION;

97
app/webpack.config.js Normal file
View File

@@ -0,0 +1,97 @@
const path = require('path');
const webpack = require('webpack');
const execSync = require('child_process').execSync;
let commitShaId;
try {
commitShaId = '#'+execSync('git rev-parse HEAD').toString().substr(0,10);
} catch (e) {}
module.exports = {
entry: {
upload: './src/upload.js',
download: './src/download.js',
},
output: {
path: path.resolve(__dirname, '../public/app'),
publicPath: '/app/',
filename: '[name].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
filename: "common.js",
name: "common"
}),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: process.env.NODE_ENV !== 'development' ? '"production"' : '"development"',
},
PSITRANSFER_VERSION: '"' + (process.env.PSITRANSFER_VERSION || commitShaId || 'dev') + '"'
}),
],
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.common.js'
}
},
devServer: {
historyApiFallback: true,
noInfo: true,
proxy: [
// Proxy requests to BE in DEV mode
// https://webpack.github.io/docs/webpack-dev-server.html#proxy
{
// everything except of js, html, css
context: ['/**', '!/**.js', '!/**.html', '!/**.css'],
target: 'http://localhost:3000/'
}
]
},
performance: {
hints: false
},
devtool: '#eval-source-map'
};
if (process.env.NODE_ENV !== 'development') {
module.exports.devtool = '#source-map';
let commit;
// http://vue-loader.vuejs.org/en/workflow/production.html
module.exports.plugins = [
...module.exports.plugins,
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: false
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
];
}