Initial commit

This commit is contained in:
Mark van Renswoude 2018-03-19 07:48:05 +01:00
commit 50f83b47d1
16 changed files with 19686 additions and 0 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"plugins": ["syntax-dynamic-import"]
}

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
data
custom/*.js
public/dist
config.js
*.sublime-workspace

8
Recv.sublime-project Normal file
View File

@ -0,0 +1,8 @@
{
"folders":
[
{
"path": "."
}
]
}

12
config.example.js Normal file
View File

@ -0,0 +1,12 @@
const path = require('path');
module.exports = {
port: 3000,
nodeModulesPath: path.join(__dirname, 'node_modules'),
userDatabasePath: path.join(__dirname, 'data', 'users'),
fileUploadPath: path.join(__dirname, 'data', 'files'),
fileUploadPublicPath: '/files',
jwtSecret: 'change me to a random generated string'
};

165
index.js Normal file
View File

@ -0,0 +1,165 @@
'use strict'
const config = require('./config');
const JsonUserDatabase = require('./lib/JsonUserDatabase');
const _ = require('lodash');
const express = require('express');
const bodyParser = require('body-parser');
const tus = require('tus-node-server');
const jwt = require('jsonwebtoken');
const path = require('path');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const webpackConfig = require('./webpack.config.js');
/*
function metadataToObject(stringValue)
{
const keyValuePairList = stringValue.split(',');
return _.reduce(keyValuePairList , (metadata, keyValuePair) => {
let [key, base64Value] = keyValuePair.split(' ');
metadata[key] = new Buffer(base64Value, "base64").toString("ascii");
return metadata;
}, {});
}
*/
function checkAuthorization(req, res, onVerified)
{
if (!req.headers.authorization || req.headers.authorization.split(' ')[0] !== 'Bearer')
{
res.sendStatus(400);
return;
}
var token = req.headers.authorization.split(' ')[1];
jwt.verify(token, config.jwtSecret, (err, decoded) =>
{
if (err)
{
res.sendStatus(403);
return;
}
onVerified(decoded);
});
}
(async function()
{
const isDevelopment = process.env.NODE_ENV !== 'production';
const userDatabase = new JsonUserDatabase(config.userDatabasePath);
await userDatabase.load();
const tusServer = new tus.Server();
tusServer.datastore = new tus.FileStore({
path: config.fileUploadPublicPath,
directory: config.fileUploadPath
});
/*
tusServer.on(tus.EVENTS.EVENT_UPLOAD_COMPLETE, (event) =>
{
console.log(event);
const metadata = metadataToObject(event.file.upload_metadata);
jwt.verify(metadata.token, config.jwtSecret, (err, decoded) =>
{
if (err)
return;
const filePath = path.join(config.fileUploadPath, event.file.id);
console.log(filePath);
// TODO save metadata for file and notify people
});
});
*/
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
// Token API
app.post('/token/upload', async (req, res) =>
{
if (!req.body.code)
{
res.sendStatus(400);
return;
}
if (await userDatabase.isValidCode(req.body.code))
{
jwt.sign({
code: req.body.code
}, config.jwtSecret, (err, token) =>
{
if (err)
res.sendStatus(500);
else
res.send(token);
});
}
else
res.sendStatus(403);
});
// Upload API
app.post('/complete', (req, res) =>
{
if (!req.body.files)
{
res.sendStatus(400);
return;
}
checkAuthorization(req, res, (decoded) =>
{
console.log(req.body.files);
// TODO save set
});
});
// Tus upload
const uploadApp = express();
uploadApp.all('*', (req, res) =>
{
checkAuthorization(req, res, (decoded) =>
{
tusServer.handle(req, res);
});
});
app.use('/upload', uploadApp);
// Frontend
if (isDevelopment)
{
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath
}));
app.use(webpackHotMiddleware(compiler));
}
app.use(express.static(path.join(__dirname, 'public', 'dist')));
var server = app.listen(config.port, () => console.log('Recv running on port ' + server.address().port));
})();

91
lib/JsonUserDatabase.js Normal file
View File

@ -0,0 +1,91 @@
const debug = require('debug')('recv:JsonUserDatabase');
const uuidv4 = require('uuid/v4');
const fs = require('mz/fs');
const mkdir = require('mkdir-promise');
const path = require('path');
class JsonUserDatabase
{
constructor(path)
{
this.path = path;
this.codes = {};
this.users = {};
}
async load()
{
debug('loading database from ' + this.path);
try
{
var files = await fs.readdir(this.path);
}
catch(err)
{
if (err.code == 'ENOENT')
// Path does not exist, no worries
files = null;
else
throw err;
}
if (!files)
return;
for (var i = 0; i < files.length; i++)
{
var fullPath = path.join(this.path, files[i]);
var stats = await fs.lstat(fullPath);
if (stats.isFile())
{
debug('loading ' + fullPath);
try
{
var userInfo = JSON.parse(await fs.readFile(fullPath));
if (userInfo.type !== 'user')
throw new Error('unsupported file type: ' + userInfo.type);
this.users[userInfo.uid] = userInfo;
}
catch (err)
{
console.error('error while loading file ' + fullPath + ', skipped:');
console.error(err);
}
}
}
debug(Object.keys(this.users).length + ' user(s) loaded');
}
addUser(info)
{
var userId = uuidv4();
// TODO add user
// TODO save file
return userId;
}
isValidCode(code)
{
debug('validating code: ' + code);
// TODO check code
var valid = true;
debug('valid = ' + valid);
return valid;
}
}
module.exports = JsonUserDatabase;

18748
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "recv",
"version": "1.0.0",
"description": "Recv - self-hosted web file transfer",
"main": "index.js",
"scripts": {
"dev": "node index.js",
"build": "webpack --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://www.github.com/MvRens/Recv"
},
"author": "Mark van Renswoude <mark@x2software.net>",
"license": "Unlicense",
"dependencies": {
"body-parser": "^1.18.2",
"debug": "^3.1.0",
"express": "^4.16.2",
"jsonwebtoken": "^8.1.1",
"mkdir-promise": "^1.0.0",
"mz": "^2.7.0",
"npm": "^5.7.1",
"tus-node-server": "^0.2.10",
"uuid": "^3.2.1",
"lodash": "^4.17.5"
},
"devDependencies": {
"axios": "^0.18.0",
"babel-loader": "^7.1.4",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"css-loader": "^0.28.10",
"html-webpack-plugin": "^3.0.6",
"node-sass": "^4.7.2",
"purecss": "^1.0.0",
"sass-loader": "^6.0.7",
"style-loader": "^0.20.2",
"tus-js-client": "^1.4.5",
"uppy": "^0.23.2",
"vue": "^2.5.13",
"vue-i18n": "^7.4.2",
"vue-loader": "^14.2.1",
"vue-router": "^3.0.1",
"vue-style-loader": "^4.0.2",
"vue-template-compiler": "^2.5.13",
"webpack": "^4.1.0",
"webpack-cli": "^2.0.10",
"webpack-dev-middleware": "^3.0.1",
"webpack-hot-middleware": "^2.21.2"
}
}

183
public/src/App.vue Normal file
View File

@ -0,0 +1,183 @@
<template>
<div id="app">
<div v-cloak>
<div class="notificationContainer">
<div class="notification" :class="{ error: notification != null && notification.error }" v-if="notification != null" @click.prevent="hideNotification">
<span class="message">{{ notification.message }}</span>
</div>
</div>
<div class="content">
<div class="logo">
<img src="/images/logo.png">
</div>
<router-view></router-view>
</div>
<div class="disclaimer">
{{ $t('disclaimer') }}
</div>
</div>
</div>
</template>
<script>
import _ from 'lodash';
import shared from './shared';
if (typeof customMessages !== 'undefined')
{
_.merge(messages, customMessages);
if (customMessages.hasOwnProperty('allLocales'))
{
for (var key in messages)
{
if (key !== 'allLocales' && messages.hasOwnProperty(key))
_.merge(messages[key], customMessages.allLocales);
}
}
}
export default {
name: 'app',
data () {
return {
notification: null
}
},
created()
{
var self = this;
document.title = self.$i18n.t('title');
self.notificationTimer = null;
shared.$on('showNotification', (message, error) =>
{
var self = this;
self.notification = {
message: message,
error: error
};
if (self.notificationTimer != null)
clearTimeout(self.notificationTimer);
self.notificationTimer = setTimeout(() =>
{
self.notification = null;
self.notificationTimer = null;
}, 5000);
});
shared.$on('hideNotification', () =>
{
var self = this;
self.notification = null;
if (self.notificationTimer != null)
{
clearTimeout(self.notificationTimer);
self.notificationTimer = null;
}
});
}
}
</script>
<style lang="scss">
@import '../../node_modules/purecss/build/base.css';
@import '../../node_modules/purecss/build/buttons.css';
@import '../../node_modules/purecss/build/forms.css';
@import '../../node_modules/purecss/build/grids.css';
/* open-sans-regular - latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
src: url('/fonts/open-sans-v15-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Open Sans Regular'), local('OpenSans-Regular'),
url('/fonts/open-sans-v15-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('/fonts/open-sans-v15-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('/fonts/open-sans-v15-latin-regular.woff') format('woff'), /* Modern Browsers */
url('/fonts/open-sans-v15-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('/fonts/open-sans-v15-latin-regular.svg#OpenSans') format('svg'); /* Legacy iOS */
}
body
{
background-color: #f0f0f0;
font-family: 'Open Sans', sans-serif;
font-size: 12pt;
}
#app
{
margin-left: auto;
margin-right: auto;
max-width: 768px;
}
.content
{
background-color: white;
margin: 2rem;
padding: 2rem;
box-shadow: 0 0 15px #c0c0c0;
}
.notificationContainer
{
position: fixed;
top: 1.5rem;
z-index: 666;
width: 512px;
left: 50%;
}
.notification
{
background: #d5e8f6;
border: solid 1px rgba(0, 0, 0, 0.25);
box-shadow: 0 0 10px #c0c0c0;
color: black;
cursor: pointer;
padding: .5em;
margin-bottom: 2rem;
position: relative;
left: -50%;
}
.notification .message
{
white-space: pre;
}
.notification.error
{
background: #973a38;
}
.logo
{
margin-bottom: 2rem;
text-align: center;
}
.disclaimer
{
font-size: 8pt;
text-align: center;
}
</style>

33
public/src/app.js Normal file
View File

@ -0,0 +1,33 @@
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import VueRouter from 'vue-router';
import App from './App.vue';
import messages from './lang';
Vue.use(VueI18n);
Vue.use(VueRouter);
const i18n = new VueI18n({
locale: navigator.language.split('-')[0],
fallbackLocale: 'en',
messages: messages
});
const Landing = () => import('./route/Landing.vue');
const Upload = () => import('./route/Upload.vue');
const router = new VueRouter({
routes: [
{ path: '/', component: Landing },
{ path: '/c/:codeParam', component: Landing, props: true },
{ path: '/u/:codeParam', component: Upload, props: true }
]
});
new Vue({
el: '#app',
i18n,
router,
render: h => h(App)
});

11
public/src/index.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recv</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

47
public/src/lang.js Normal file
View File

@ -0,0 +1,47 @@
export default {
en: {
title: 'File upload - Recv',
disclaimer: '',
landing: {
invitePlaceholder: 'Code',
inviteButton: 'Check code',
inviteButtonChecking: 'Checking...',
loginButton: 'Log in'
},
notification: {
invalidCode: 'The specified code is invalid or has expired'
},
uppyDashboard: {
done: 'Done',
dropPaste: 'Drop files here, paste or',
browse: 'browse'
}
},
nl: {
title: 'Bestandsoverdracht - Recv',
disclaimer: '',
landing: {
invitePlaceholder: 'Code',
inviteButton: 'Controleer code',
inviteButtonChecking: 'Controleren...',
loginButton: 'Inloggen'
},
notification: {
invalidCode: 'De ingevoerde code is ongeldig of verlopen'
},
uppyDashboard: {
done: 'Gereed',
dropPaste: 'Sleep bestanden, plak of ',
browse: 'selecteer'
}
}
}

View File

@ -0,0 +1,94 @@
<template>
<div id="landing">
<form class="pure-form pure-form-stacked" @submit.prevent="checkCode">
<fieldset class="pure-group">
<input type="text" class="pure-input-1-2" v-model="code.value" :placeholder="$t('landing.invitePlaceholder')">
</fieldset>
<button type="submit" class="pure-button pure-button-primary" :disabled="code.value.trim() == '' || code.checking">{{ $t(code.checking ? 'landing.inviteButtonChecking' : 'landing.inviteButton') }} <span v-if="code.checking"><i class="fas fa-spinner fa-pulse"></i></span></button>
</form>
</div>
</template>
<script>
import axios from 'axios';
import shared from '../shared';
export default {
name: 'app',
props: [
'codeParam'
],
data () {
return {
code: {
value: '',
checking: false
}
}
},
created()
{
var self = this;
if (self.codeParam)
{
self.code.value = self.codeParam;
self.checkCode();
}
},
methods: {
checkCode()
{
var self = this;
if (self.code.checking)
return;
self.code.checking = true;
axios.post('/token/upload', {
code: self.code
})
.then((response) =>
{
shared.token = response.data;
self.$router.push({ path: '/u/' + self.code.value });
})
.catch((error) =>
{
if (error.response && error.response.status == 403)
shared.$emit('showNotification', self.$i18n.t('notification.invalidCode'), false);
else
shared.$emit('showNotification', error.message);
})
.then(() =>
{
self.code.checking = false;
});
}
}
}
</script>
<style lang="scss">
@import '../../../node_modules/uppy/src/scss/uppy.scss';
#landing
{
text-align: center;
input
{
display: inline-block;
}
.login
{
margin-top: 5em;
}
}
</style>

107
public/src/route/Upload.vue Normal file
View File

@ -0,0 +1,107 @@
<template>
<div id="upload">
<router-link to="/">Terug</router-link>
<div class="uploadTarget"></div>
</div>
</template>
<script>
import _ from 'lodash';
import shared from '../shared';
import Uppy from 'uppy/lib/core';
import Dashboard from 'uppy/lib/plugins/Dashboard';
import Tus from 'uppy/lib/plugins/Tus';
import axios from 'axios';
export default {
props: [
'codeParam',
'i18n'
],
data () {
return {
token: shared.token
}
},
created()
{
var self = this;
if (!self.token)
{
if (self.codeParam)
self.$router.push('/c/' + self.codeParam);
else
self.$router.push('/c/');
return;
}
},
mounted()
{
var self = this;
self.uppy = Uppy({
id: 'userUpload',
autoProceed: false,
debug: true
})
.use(Dashboard, {
inline: true,
target: '.uploadTarget',
replaceTargetContent: true,
locale: {
strings: {
done: self.$i18n.t('uppyDashboard.done'),
dropPaste: self.$i18n.t('uppyDashboard.dropPaste'),
browse: self.$i18n.t('uppyDashboard.browse')
}
}
})
.use(Tus, {
endpoint: '/upload/',
headers: {
Authorization: 'Bearer ' + self.token
}
})
.run();
self.uppy.on('complete', (result) =>
{
axios.post('/complete', {
files: _.map(result.successful, (file) =>
{
return {
id: file.tus.uploadUrl.substr(file.tus.uploadUrl.lastIndexOf('/') + 1),
name: file.name
};
})
},
{
headers: {
Authorization: 'Bearer ' + self.token
}
})
.then((response) =>
{
// TODO anything?
})
.catch((error) =>
{
// TODO can we convince Uppy that the files actually failed?
});
});
self.uppy.on('upload-success', (file, resp, uploadURL) => {
// Clear the uploadURL so the dashboard will not display it as a link.
// The file can't be viewed by the user anyways since JWT headers are required.
self.uppy.setFileState(file.id, {
uploadURL: false
})
});
}
}
</script>

9
public/src/shared.js Normal file
View File

@ -0,0 +1,9 @@
import Vue from 'vue';
export default new Vue({
data () {
return {
token: null
}
},
});

117
webpack.config.js Normal file
View File

@ -0,0 +1,117 @@
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './public/src/app.js',
output: {
path: path.resolve(__dirname, './public/dist'),
publicPath: '/',
filename: 'build.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
},
{
test: /\.scss$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader'
],
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
'scss': [
'vue-style-loader',
'css-loader',
'sass-loader'
]
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, './public/src/index.html')
})
],
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
},
devServer: {
historyApiFallback: true,
noInfo: true,
overlay: true
},
performance: {
hints: false
},
devtool: '#eval-source-map'
}
if (process.env.NODE_ENV === 'production')
{
module.exports.devtool = '#source-map'
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: false
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
]);
}
else
{
module.exports.entry = [
module.exports.entry,
'webpack-hot-middleware/client'
];
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
]);
}