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

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.idea
data
temp
node_modules
app/node_modules
npm-debug.log
scripts
docs

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md, *.pug]
trim_trailing_whitespace = false
[*.yml]
indent_style = space

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.idea
data
temp
node_modules
npm-debug.log

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v7

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
FROM node:7-alpine
ENV PSITRANSFER_UPLOAD_DIR=/data \
NODE_ENV=production
MAINTAINER Christoph Wiechert <wio@psitrax.de>
WORKDIR /app
ADD *.js package.json README.md /app/
ADD lib /app/lib
ADD app /app/app
ADD public /app/public
# Rebuild the frontend apps
RUN cd app && \
NODE_ENV=dev npm install --quiet 1>/dev/null && \
npm run build && \
cd .. && rm -rf app
# Install backend dependencies
RUN mkdir /data && \
chown node /data && \
npm install --quiet 1>/dev/null
EXPOSE 3000
VOLUME ["/data"]
USER node
HEALTHCHECK CMD wget -O /dev/null -q http://localhost:3000
CMD ["node", "app.js"]

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
Copyright 2017 Christoph Wiechert
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# PsiTransfer
Simple open source self-hosted file sharing solution.
* Supports many and very big files (Streams ftw)
* Resumable up- and downloads ([TUS](https://tus.io))
* Set an expire-time for your upload bucket
* One-time downloads
* Password protected downloads ([AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))
* Requires Node >=7.4
![Screenshot](https://raw.githubusercontent.com/psi-4ward/psitransfer/docs/psitransfer.gif)
**Demo**: https://transfer.psi.cx
## Quickstart
### Docker (recommended)
```bash
$ docker run -p 0.0.0.0:3000:3000 -v $PWD/data:/data psitrax/psitransfer
# data volume needs UID 1000
$ sudo chown -R 1000 $PWD/data
```
### Manual
```bash
# Be sure to have NodeJS >= 7.4
$ node -v
v7.4.0
# Download and extract latest release package from
# https://github.com/psi-4ward/psitransfer/releases
# Install dependencies and start the app
$ NODE_ENV=production npm install
$ npm start
```
### Configuration
There are some configs in `config.js` like port and data-dir.
You can:
* Edit the `config.js` **(not recommend)**
* Add a `config.production.js` where `production` is the value from `NODE_ENV`
See `config.dev.js`
* Define environment Variables like `PSITRANSFER_UPLOAD_DIR`
### Customization
`public/upload.html` and `download.html` are kept simple.
You can alter these files and add your logo and styles.
The following elements are mandatory:
`common.js` and respectively `upload.js`, `download.js` as well as `<div id="upload">`, `<div id="download">`
Please keep a footnote like *Powered by PsiTransfer* :)
### Debug
Psitransfer uses [debug](https://github.com/visionmedia/debug):
```bash
DEBUG=psitransfer:* npm start
```
## License
[BSD](LICENSE)

29
app.js Normal file
View File

@@ -0,0 +1,29 @@
'use strict';
const config = require('./config');
const app = require('./lib/endpoints');
/**
* Naming:
* sid: Group of files
* key: File
* fid: {sid}++{key}
*/
const server = app.listen(config.port, config.iface, () => {
console.log(`PsiTransfer listening on http://${config.iface}:${config.port}`);
});
// graceful shutdown
function shutdown() {
console.log('PsiTransfer shutting down...');
server.close(() => {
process.exit(0);
});
setTimeout(function() {
console.log('Could not close connections in time, forcefully shutting down');
process.exit(0);
}, 180 * 1000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

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
})
];
}

5
config.dev.js Normal file
View File

@@ -0,0 +1,5 @@
'use strict';
module.exports = {
accessLog: 'dev'
};

51
config.js Normal file
View File

@@ -0,0 +1,51 @@
'use strict';
const path = require('path');
const fsp = require('fs-promise');
// Default Config
// Do not edit this, generate a config.<ENV>.js for your NODE_ENV
// or use ENV-VARS like PSITRANSFER_PORT=8000
const config = {
uploadDir: path.resolve(__dirname + '/data'),
port: 3000,
iface: '0.0.0.0',
// retention options in seconds:label; first one is the default
retentions: {
"one-time": "one time download",
3600: "1 Hour",
21600: "6 Hours",
86400: "1 Day",
259200: "3 Days",
604800: "1 Week",
1209600: "2 Weeks",
2419200: "4 Weeks",
4838400: "8 Weeks"
},
defaultRetention: 604800,
mailTemplate: 'mailto:?subject=File Transfer&body=You can download the files here: %%URL%%',
// see https://github.com/expressjs/morgan
// set to false to disable logging
accessLog: ':date[iso] :method :url :status :response-time :remote-addr'
};
// Load NODE_ENV specific config
const envConfFile = path.resolve(__dirname, `config.${process.env.NODE_ENV}.js`);
if(process.env.NODE_ENV && fsp.existsSync(envConfFile)) {
Object.assign(config, require(envConfFile));
}
// Load config from ENV VARS
let envName;
for (let k in config) {
envName = 'PSITRANSFER_'+ k.replace(/([A-Z])/g, $1 => "_" + $1).toUpperCase();
if(process.env[envName]) {
if(typeof config[k] === 'number') {
config[k] = parseInt(process.env[envName], 10);
} else {
config[k] = process.env[envName];
}
}
}
module.exports = config;

BIN
docs/psitransfer.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

118
lib/db.js Normal file
View File

@@ -0,0 +1,118 @@
'use strict';
const fsp = require('fs-promise');
const path = require('path');
const debug = require('debug')('psitransfer:db');
module.exports = class DB {
constructor(uploadDir, store) {
this.initialized = false;
this.db = {};
this.expireTimers = {};
this.uploadDir = uploadDir;
this.store = store;
// delete expired files
const gc = () => {
let sid,f,expires;
for (sid of Object.keys(this.db)) {
for (f of this.db[sid]) {
// no removal of one-time downloads
if(!Number.isInteger(f.metadata.retention)) return;
expires = (+f.metadata.createdAt) + (+f.metadata.retention * 1000) - Date.now();
if(expires <= 0) {
debug(`Expired ${sid}++${f.key}`);
this.remove(sid, f.key).catch(e => console.error(e));
}
}
}
};
setInterval(gc, 60*1000);
}
init() {
if(this.initialized) return;
this.initialized = true;
try {
this._sync();
} catch(e) {
this.initialized = false;
e.message = `db initialization failed with error ${e.message}`;
throw e;
}
}
/**
* @private
*/
_sync() {
fsp.ensureDirSync(this.uploadDir);
fsp.readdirSync(this.uploadDir).forEach((sid) => {
this._import(sid);
});
}
/**
* @private
*/
_import(sid) {
const p = path.resolve(this.uploadDir, sid);
const stat = fsp.statSync(p);
if(!stat.isDirectory()) return;
fsp.readdirSync(p).forEach(async (key) => {
if(path.extname(key) !== '') {
return Promise.resolve();
}
try {
let info = await this.store.info(`${sid}++${key}`);
this.add(sid, key, info);
} catch(e) {
console.error(e);
}
});
}
add(sid, key, data) {
if(!this.initialized) throw new Error('DB not initialized_');
if(!this.db[sid]) this.db[sid] = [];
data.key = key;
const old = this.db[sid].findIndex(i => i.key === key);
if(old !== -1) {
this.db[sid].splice(old, 1, data);
debug(`Updated ${sid}++${key}`);
} else {
this.db[sid].push(data);
debug(`Added ${sid}++${key}`);
}
}
async remove(sid, key) {
if(!this.initialized) throw new Error('DB not initialized');
debug(`Remove ${sid}++${key}`);
await this.store.del(sid + '++' + key);
const i = this.get(sid).find(item => item.key === key);
this.get(sid).splice(i, 1);
if(this.get(sid).length === 0) {
delete this.db[sid];
await fsp.rmdir(path.resolve(this.uploadDir, sid));
}
}
get(sid) {
return this.db[sid];
}
};

145
lib/endpoints.js Normal file
View File

@@ -0,0 +1,145 @@
'use strict';
const config = require('../config');
const tusboy = require('tusboy').default;
const express = require('express');
const morgan = require('morgan');
const compression = require('compression');
const Store = require('./store');
const uuid = require('uuid/v4');
const path = require('path');
const fs = require("fs");
const tusMeta = require('tus-metadata');
const assert = require('assert');
const AES = require("crypto-js/aes");
const debug = require('debug')('psitransfer:main');
const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString();
const store = new Store(config.uploadDir);
const Db = require('./db');
const db = new Db(config.uploadDir, store);
db.init();
const app = express();
app.disable('x-powered-by');
app.use(compression());
if(config.accessLog) {
app.use(morgan(config.accessLog));
}
// Static files
app.use('/app', express.static(path.join(__dirname, '../public/app')));
app.use('/assets', express.static(path.join(__dirname, '../public/assets')));
// Upload App
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/html/upload.html'));
});
// Config
app.get('/config.json', (req, res) => {
res.json({
retentions: config.retentions,
defaultRetention: config.defaultRetention,
mailTemplate: config.mailTemplate
});
});
// List files / Download App
app.get('/:sid', (req, res, next) => {
if(req.url.endsWith('.json')) {
const sid = req.params.sid.substr(0, req.params.sid.length-5);
if(!db.get(sid)) return res.status(404).end();
res.json(db.get(sid).map(data => {
const item = Object.assign(data, {url: `/files/${sid}++${data.key}`});
if(item.metadata.password) {
return AES.encrypt(JSON.stringify(data), item.metadata.password).toString();
} else {
return item;
}
}));
} else {
if(!db.get(req.params.sid)) return next();
res.sendFile(path.join(__dirname, '../public/html/download.html'));
}
});
// Download single file
app.get('/files/:fid', async(req, res, next) => {
// let tusboy handle HEAD with Tus Header
if(req.method === 'HEAD' && req.get('Tus-Resumable')) return next();
debug(`Download ${req.params.fid}`);
try {
const info = await store.info(req.params.fid); // throws on 404
res.download(store.getFilename(req.params.fid), info.metadata.name);
// remove one-time files after download
if(info.metadata.retention === 'one-time') {
res.on('finish', async () => {
await db.remove(info.metadata.sid, info.metadata.key);
});
}
} catch(e) {
res.status(404).send(errorPage.replace('%%ERROR%%', e.message));
}
});
// Upload file
app.use('/files',
function(req, res, next) {
if(req.method === 'GET') return res.status(405).end();
if(req.method === 'POST') {
// validate meta-data
// !! tusMeta.encode supports only strings !!
const meta = tusMeta.decode(req.get('Upload-Metadata'));
try {
assert(meta.name, 'tus meta prop missing: name');
assert(meta.sid, 'tus meta prop missing: sid');
assert(meta.retention, 'tus meta prop missing: retention');
assert(Object.keys(config.retentions).indexOf(meta.retention) >= 0,
`invalid tus meta prop retention. Value ${meta.retention} not in [${Object.keys(config.retentions).join(',')}]`);
meta.key = uuid();
meta.createdAt = Date.now().toString();
// store changed metadata for tusboy
req.headers['upload-metadata'] = tusMeta.encode(meta);
// for tusboy getKey()
req.FID = meta.sid + '++' + meta.key;
db.add(meta.sid, meta.key, {
"isPartial": true,
metadata: meta
});
}
catch(e) {
return res.status(400).end(e.message);
}
}
next();
},
// let tusboy handle the upload
tusboy(store, {
getKey: req => req.FID,
afterComplete: (req, upload, fid) => {
db.add(upload.metadata.sid, upload.metadata.key, upload);
debug(`Completed upload ${fid}, size=${upload.size} name=${upload.metadata.name}`);
},
})
);
app.use((req, res, next) => {
res.status(404).send(errorPage.replace('%%ERROR%%', 'Download bucket not found.'));
});
module.exports = app;

113
lib/store.js Normal file
View File

@@ -0,0 +1,113 @@
'use strict';
const fsp = require('fs-promise');
const path = require('path');
const Transform = require('stream').Transform;
const debug = require('debug')('psitransfer:store');
const httpErrors = require('http-errors');
class StreamLen extends Transform {
constructor(options) {
super(options);
this.bytes = 0;
}
_transform(chunk, encoding, cb) {
this.bytes += chunk.length;
this.push(chunk);
cb();
}
}
// TODO ???: make tus-store complaint: https://github.com/blockai/abstract-tus-store
class Store {
constructor(targetDir) {
this.dir = path.normalize(targetDir);
}
getFilename(fid) {
let p = path.resolve(this.dir, fid.replace('++', '/'));
if(!p.startsWith(this.dir)) {
throw new Error('file name not in jail path. aborting');
}
return p;
}
async create(fid, opts = {}) {
debug(`New File ${this.getFilename(fid)}`);
await fsp.ensureDir(path.dirname(this.getFilename(fid)));
await fsp.writeJson(this.getFilename(fid) + '.json', Object.assign(opts, {
isPartial: true
}));
return {uploadId: fid};
}
async info(fid) {
try {
const info = await fsp.readJson(this.getFilename(fid) + '.json');
const stat = await fsp.stat(this.getFilename(fid));
info.size = stat.size;
info.offset = stat.size;
debug(`Fetched Fileinfo ${this.getFilename(fid)}`);
return info;
} catch(e) {
if(e.code === 'ENOENT') {
throw httpErrors.NotFound();
}
throw e;
}
}
async append(fid, readStream, offset) {
debug(`Append Data to ${this.getFilename(fid)}`);
const uploadSize = new StreamLen();
const ws = fsp.createWriteStream(this.getFilename(fid), {flags: 'a', start: offset});
const ret = new Promise((resolve, reject) => {
ws.on('finish', async() => {
const info = await this.info(fid);
if(info.size >= info.uploadLength) delete info.isPartial;
await fsp.writeJson(this.getFilename(fid) + '.json', info);
debug(`Finished appending Data to ${this.getFilename(fid)}`);
return resolve({ offset: info.offset, upload: info });
});
ws.on('error', reject);
// End writeStream on connection abort and wait for drain
readStream.on('close', () => {
ws.on('drain', () => {
ws.end();
});
});
});
readStream.pipe(uploadSize).pipe(ws);
return ret;
}
createReadStream(fid, start, end, cb) {
debug(`Create ReadStream for ${this.getFilename(fid)}`);
this.info(fid).then(info => {
let contentLength = info.size;
if(start > 0) {
if(!end) end = info.size - 1;
contentLength = end - start + 1
}
cb({ contentLength, metadata: info.metadata, info });
});
return fsp.createReadStream(this.getFilename(fid), {start, end});
}
async del(fid) {
debug(`Delete ${this.getFilename(fid)}`);
await fsp.unlink(this.getFilename(fid) + '.json');
await fsp.unlink(this.getFilename(fid));
}
}
module.exports = Store;

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "psitransfer",
"version": "1.0.0",
"description": "Open self-hoste file-sharing solution",
"keywords": ["share","upload","transfer","files","wetransfer"],
"repository": "psi-4ward/psitransfer",
"bugs": "https://github.com/psi-4ward/psitransfer/issues",
"main": "app.js",
"dependencies": {
"compression": "^1.6.2",
"crypto-js": "^3.1.9-1",
"debug": "^2.6.0",
"express": "^4.14.0",
"fs-promise": "^2.0.2",
"http-errors": "^1.5.1",
"morgan": "^1.7.0",
"tus-metadata": "^1.0.2",
"tusboy": "^1.1.1",
"uuid": "^3.0.1"
},
"devDependencies": {
},
"scripts": {
"start": "NODE_ENV=production node app.js",
"dev": "NODE_ENV=dev DEBUG=psitransfer:* nodemon -i app -i dist -i data app.js",
"debug": "node --inspect app.js"
},
"engines": {
"node": ">= 7.4.0",
"npm": ">= 3"
},
"author": "Christoph Wiechert <wio@psitrax.de>",
"contributors": [],
"license": "BSD-2-Clause"
}

2
public/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

BIN
public/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

45
public/html/download.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PsiTransfer</title>
<link href="/assets/favicon.ico" rel="icon" type="image/x-icon"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
<style>
html {
position: relative;
min-height: 100%;
}
body > .container {
padding-bottom: 50px;
position: relative;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
height: 40px;
line-height: 40px;
}
</style>
</head>
<body>
<div class="container">
<h1>
<i class="fa fa-fw fa-cloud-download" style="color: #0275D8"></i>
PsiTransfer
</h1>
<hr>
<div id="download"></div>
</div>
<footer class="footer">
<div class="container text-right">
<span class="text-muted"><a href="http://psitransfer.psi.cx" target="_blank">Powered by PsiTransfer</a></span>
</div>
</footer>
<script src="/app/common.js"></script>
<script src="/app/download.js"></script>
</body>
</html>

51
public/html/error.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PsiTransfer</title>
<link href="/assets/favicon.ico" rel="icon" type="image/x-icon"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
<style>
html {
position: relative;
min-height: 100%;
}
body > .container {
padding-bottom: 50px;
position: relative;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
height: 40px;
line-height: 40px;
}
</style>
</head>
<body>
<div class="container">
<h1>
<i class="fa fa-fw fa-cloud-upload" style="color: #0275D8"></i>
PsiTransfer
</h1>
<hr>
<p class="alert alert-danger">
<strong>
<i class="fa fa-fw fa-exclamation-triangle"></i>
%%ERROR%%
</strong>
</p>
<p>
<a title="New Upload" class="btn btn-sm btn-info" href="/"><i class="fa fa-fw fa-cloud-upload"></i> new upload</a>
</p>
</div>
<footer class="footer">
<div class="container text-right">
<span class="text-muted"><a href="http://psitransfer.psi.cx" target="_blank">Powered by PsiTransfer</a></span>
</div>
</footer>
</body>
</html>

69
public/html/upload.html Normal file
View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PsiTransfer</title>
<link href="/assets/favicon.ico" rel="icon" type="image/x-icon"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
<style>
html {
position: relative;
min-height: 100%;
}
body > .container {
padding-bottom: 50px;
position: relative;
}
#dropHelper {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: -1;
}
.drag #dropHelper {
background-color: rgba(40, 40, 40, 0.45);
border: 4px dashed #eeeeee;
z-index: 100;
font: normal normal normal 14px/1 FontAwesome;
text-rendering: auto;
display: flex;
align-items: center;
justify-content: center;
}
.drag #dropHelper:before {
content: "\f019";
font-size: 8em;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
height: 40px;
line-height: 40px;
}
</style>
</head>
<body>
<div id="dropHelper"></div>
<div class="container">
<h1>
<i class="fa fa-fw fa-cloud-upload" style="color: #0275D8"></i>
PsiTransfer
</h1>
<hr>
<div id="upload"></div>
</div>
<footer class="footer">
<div class="container text-right">
<span class="text-muted"><a href="http://psitransfer.psi.cx" target="_blank">Powered by PsiTransfer</a></span>
</div>
</footer>
<script src="/app/common.js"></script>
<script src="/app/upload.js"></script>
</body>
</html>

18
tmp/scripts/traffic-limit.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# http://www.codeoriented.com/how-to-limit-network-bandwidth-for-a-specific-tcp-port-on-ubuntu-linux/
IF=$2
BAND=$1
PORT=$3
if [ -z "$3" ] ; then
echo $0 10kbps wlan0 8080
exit 1
fi
tc qdisc del root dev $IF
tc qdisc add dev $IF root handle 1:0 htb default 10
tc class add dev $IF parent 1:0 classid 1:10 htb rate $BAND prio 0
tc filter add dev $IF parent 1:0 prio 0 protocol ip u32 match ip protocol 4 0xff match ip dport $PORT 0xffff flowid 1:10
tc qdisc show dev $IF

116
tmp/test/db.spec.js Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
const fsp = require("fs-promise");
const path = require("path");
const Db = require("./db");
const Store = require("./store");
const uploadDir = path.resolve(__dirname, "data");
describe("psitransfer db", () => {
let db;
let store;
beforeEach(() => {
// TODO: atomic name
store = new Store(uploadDir);
db = new Db(
uploadDir,
store
);
});
afterEach((next) => {
fsp.remove(uploadDir, next);
});
it("should properly construct", () => {
expect(db.initialized).toBeFalsy();
expect(db.db).toEqual({});
expect(db.expireTimers).toEqual({});
expect(db.store).toBe(store);
expect(db.uploadDir).toEqual(uploadDir);
});
it("should call sync on init", () => {
spyOn(db, "sync");
db.initialized = false;
db.init();
expect(db.sync).toHaveBeenCalled();
});
it("shouldn't call sync on initialize if already bootstrapped ", () => {
spyOn(db, "sync");
db.initialized = true;
db.init();
expect(db.sync).not.toHaveBeenCalled();
});
describe("testing CRUD", () => {
let sid;
let uuid;
let metaData;
beforeEach(() => {
sid = "221813e1688d";
uuid = "e40bc20e-5be3-4906-903c-895f05e49efe";
metaData = {
uploadLength: 0,
metadata: {
sid,
retention: "259200",
password: "",
name: "test.txt",
key: uuid,
createdAt: "" + Date.now()
},
size: 0,
offset: 0
};
fsp.ensureDirSync(uploadDir);
fsp.ensureDirSync(path.resolve(uploadDir, sid));
fsp.writeFileSync(path.resolve(uploadDir, sid, uuid), "");
fsp.writeFileSync(path.resolve(uploadDir, sid, uuid + ".json"), JSON.stringify(metaData));
});
it("should sync upload dir", () => {
spyOn(db, "import");
db.sync();
expect(db.import).toHaveBeenCalledWith(sid);
});
it("should import existing files", async() => {
spyOn(db.store, "info").and.returnValue(Promise.resolve(metaData));
spyOn(db, "add");
db.initialized = true;
await db.import(sid);
expect(db.add).toHaveBeenCalledWith(sid, uuid, metaData);
});
it("should remove files", async() => {
db.initialized = true;
db.db[sid] = [ metaData ];
spyOn(store, "del").and.returnValue(Promise.resolve());
spyOn(fsp, "rmdir").and.returnValue(Promise.resolve());
await db.remove(sid, uuid);
expect(store.del).toHaveBeenCalledWith(sid + '++' + uuid);
expect(db.db[sid]).not.toBeDefined();
expect(fsp.rmdir).toHaveBeenCalledWith(path.resolve(uploadDir, sid));
});
it("should add new files to sid", () => {
db.initialized = true;
spyOn(db, "registerRemove");
db.add(sid, uuid, metaData);
expect(db.registerRemove).toHaveBeenCalled();
expect(db.db[sid]).toEqual([metaData]);
});
it("should update already existing files", () => {
db.initialized = true;
db.add(sid, uuid, Object.assign(metaData, { bacon: "yammie"}));
db.add(sid, uuid, metaData);
expect(db.db[sid]).toEqual([metaData]);
});
});
});

6
tmp/test/jasmine.json Normal file
View File

@@ -0,0 +1,6 @@
{
"spec_dir": "lib",
"spec_files": [
"**/*.[sS]pec.js"
]
}

5
tmp/test/pkgs Normal file
View File

@@ -0,0 +1,5 @@
"jasmine": "^2.5.2",
"nodemon": "^1.11.0",
"request": "^2.79.0"
"test": "node spec-runner.js"

6
tmp/test/spec-runner.js Normal file
View File

@@ -0,0 +1,6 @@
'use strict';
const Jasmine = require('jasmine');
const jasmine = new Jasmine();
jasmine.loadConfigFile('jasmine.json');
jasmine.execute();

148
tmp/test/store.spec.js Normal file
View File

@@ -0,0 +1,148 @@
"use strict";
const fsp = require("fs-promise");
const path = require("path");
const Store = require("./store");
const httpErrors = require('http-errors');
const uploadDir = path.resolve(__dirname, "data");
describe("psitransfer store", () => {
let store;
let sid;
let uuid;
let fid;
let metaData;
let info;
let stat;
beforeEach(() => {
store = new Store(uploadDir);
sid = "221813e1688d";
uuid = "e40bc20e-5be3-4906-903c-895f05e49efe";
fsp.ensureDirSync(path.resolve(uploadDir, sid));
fid = `${sid}++${uuid}`;
metaData = {
uploadLength: 0,
metadata: {
sid,
retention: "259200",
password: "",
name: "test.txt",
key: uuid,
createdAt: "" + Date.now()
},
size: 0,
offset: 0
};
info = {
offset: 100,
uploadLength: 100,
metadata: {
sid: 'fea60a1beba6',
retention: '259200',
password: '',
name: 'bacon.ham',
key: '1215182b-ca57-4212-9e87-c7028190ff69',
createdAt: '1483890816120'
},
isPartial: true,
};
stat = {
size: 287
};
});
afterEach((next) => {
fsp.remove(uploadDir, next);
});
it("should properly construct", () => {
expect(store.dir).toEqual(uploadDir);
});
it("should create new files", async() => {
let fileName = store.getFilename(fid);
spyOn(store, "getFilename").and.callThrough();
spyOn(fsp, "ensureDir").and.returnValue(Promise.resolve());
spyOn(fsp, "writeJson").and.returnValue(Promise.resolve());
expect(await store.create(fid, metaData)).toEqual({
uploadId: fid
});
expect(store.getFilename).toHaveBeenCalled();
expect(fsp.ensureDir).toHaveBeenCalled();
expect(fsp.writeJson).toHaveBeenCalled();
});
it("should evaluate file info", async() => {
spyOn(fsp, "readJson").and.returnValue(Promise.resolve(info));
spyOn(fsp, "stat").and.returnValue(Promise.resolve(stat));
expect(await store.info(fid)).toEqual(Object.assign({}, info, stat));
});
it("should throw an http error on file info if file doesn't exist", (next) => {
store.info(fid)
// TODO use always when available
// .always(() => next());
.then(() => {
// if this test fails you accidently created that file and didn't cleanup
expect(true).toBeFalsy();
next();
})
.catch((e) => {
expect(e).toEqual(jasmine.any(httpErrors.NotFound().constructor));
next();
});
});
it("should append file content", async() => {
let readStream = fsp.createReadStream('/dev/urandom', {start: 0, end: 99});
spyOn(store, "info").and.returnValue(Promise.resolve(info));
spyOn(fsp, "writeJson").and.returnValue(Promise.resolve());
spyOn(fsp, "createWriteStream").and.callThrough();
let retVal = await store.append(fid, readStream, 0);
expect(retVal).toEqual({offset: 100, upload: info});
expect(store.info).toHaveBeenCalledWith(fid);
expect(fsp.writeJson).toHaveBeenCalled();
});
it("should create a read stream", (next) => {
let cb = jasmine.createSpy('cb');
spyOn(store, "info").and.returnValue(Promise.resolve(info));
spyOn(store, "getFilename");
spyOn(fsp, "createReadStream");
store.createReadStream(fid, 0, 100, cb);
expect(store.getFilename).toHaveBeenCalledTimes(2);
expect(store.info).toHaveBeenCalled();
expect(fsp.createReadStream).toHaveBeenCalled();
setTimeout(() => {
expect(cb).toHaveBeenCalled();
next();
}, 0);
});
it("should delete files", async() => {
spyOn(fsp, "unlink");
spyOn(store, "getFilename");
await store.del(fid);
expect(fsp.unlink).toHaveBeenCalledTimes(2);
expect(store.getFilename).toHaveBeenCalledTimes(3);
});
});