Added code and upload expiration checks

Added the feature to override email templates
Added automatic refresh of disk space indicator
This commit is contained in:
Mark van Renswoude 2018-05-02 23:01:29 +02:00
parent fb746966af
commit 88beaa6f7d
20 changed files with 356 additions and 89 deletions

5
.gitignore vendored
View File

@ -1,10 +1,9 @@
node_modules
data
custom/*.js
custom/*
public/dist/*.js
public/dist/*.map
public/dist/index.html
config.js
*.sublime-workspace
npm-debug.log
/custom/images/logo.png
npm-debug.log

View File

@ -36,21 +36,28 @@ module.exports = {
alphabet: '1234567890abcdef',
length: 8,
defaultExpiration: null,
maxExpiration: null
/*
To set a maximum expiration of 7 days:
maxExpiration: {
units: ExpirationUnits.Days,
value: 7
}
defaultExpiration follows the same format
*/
},
// How often codes and uploads are checked for expiration and deleted
cleanupInterval: 300,
notifications: {
// How often the notification queue is flushed, in seconds
interval: 10,
maxAttempt: 12,
adminUrl: 'http://localhost:3001/admin/',

View File

@ -29,5 +29,8 @@ function humanFileSize(bytes, si)
<li><%= file.name %> (<%= humanFileSize(file.size, true) %>)</li>
<% }) %>
</ul>
<% if (upload.expirationDate !== null) { %>
These files will be automatically deleted after <%=upload.expirationDate.toLocaleString('en-US')%>
<% } %>
<p>You can download these files by logging in to <a href="<%= adminUrl %>"><%= adminUrl %></a></p>
<p>Cheers,<br />Recv</p>

View File

@ -17,6 +17,7 @@ else
const Repository = require('./lib/repository');
const NotificationWorker = require('./lib/workers/notification');
const ExpirationWorker = require('./lib/workers/expiration');
const express = require('express');
const bodyParser = require('body-parser');
@ -116,6 +117,8 @@ const webpackConfigFactory = require('./webpack.config.js');
var notificationWorker = new NotificationWorker(repository);
notificationWorker.start(config.notifications.interval * 1000);
var expirationWorker = new ExpirationWorker(repository);
expirationWorker.start(config.cleanupInterval * 1000);
var server = app.listen(config.port, () => console.log('Recv running on port ' + server.address().port));
}

View File

@ -214,13 +214,14 @@ module.exports = (repository) =>
}));
router.get('/maxExpiration', asyncHandler(async (req, res) =>
router.get('/expiration', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>
{
res.send(config.code.maxExpiration !== null
? config.code.maxExpiration
: { units: '', value: 1 });
res.send({
max: config.code.maxExpiration,
default: config.code.defaultExpiration
});
});
}));

View File

@ -21,7 +21,7 @@ module.exports = (repository) =>
jwt.sign({
code: req.body.code,
codeUserId: code.userId,
codeExpirationDate: code.expirationDate
codeExpirationTime: code.expirationDate !== null ? code.expirationDate.getTime() : null
}, config.jwtSecret, (err, token) =>
{
if (err)

View File

@ -45,7 +45,7 @@ module.exports = (repository, tusServer) =>
var router = express.Router();
// Upload API
router.get('/message/:code', asyncHandler(async (req, res) =>
router.get('/info/:code', asyncHandler(async (req, res) =>
{
var code = await repository.codes.get(req.params.code);
if (code === null)
@ -54,19 +54,23 @@ module.exports = (repository, tusServer) =>
return;
}
if (!code.messageHTML)
let info = {
message: null,
expirationDate: code.expirationDate !== null ? code.expirationDate.getTime() : null
};
if (code.messageHTML)
{
res.sendStatus(204);
return;
var user = await repository.users.get(code.userId);
var name = user !== null ? user.name : null;
info.message = {
name: name,
message: code.messageHTML
};
}
var user = await repository.users.get(code.userId);
var name = user !== null ? user.name : null;
res.send({
name: name,
message: code.messageHTML
});
res.send(info);
}));
router.post('/complete', asyncHandler(async (req, res) =>
@ -102,7 +106,13 @@ module.exports = (repository, tusServer) =>
return;
}
var uploadId = await repository.uploads.insert(decoded.codeUserId, decoded.code, req.body.files, decoded.codeExpirationDate);
var uploadId = await repository.uploads.insert(
decoded.codeUserId,
decoded.code,
req.body.files,
decoded.codeExpirationTime !== null ? new Date(decoded.codeExpirationTime) : null
);
await repository.notifications.insert({
userId: decoded.codeUserId,
uploadId: uploadId

View File

@ -2,6 +2,7 @@ const map = require('lodash/map');
const retry = require('async-retry');
const generate = require('nanoid/generate');
const markdown = require('markdown').markdown;
const async = require('async');
const ExpirationUnits = require('../expirationunits');
@ -197,6 +198,40 @@ class CodeRepository
});
});
}
deleteExpired()
{
var self = this;
return new Promise((resolve, reject) =>
{
let now = new Date();
self.store.find({ $where: function() { return this.expirationDate !== null && this.expirationDate < now }}, (err, docs) =>
{
if (err)
{
reject(err);
return;
}
async.eachOfSeries(docs,
async (doc) =>
{
console.log('Expired code: ' + doc._id);
await self.delete(doc._id);
},
(err) =>
{
if (err)
reject(err);
else
resolve();
});
});
});
}
}

View File

@ -2,6 +2,7 @@ const map = require('lodash/map');
const filter = require('lodash/filter');
const resolvePath = require('resolve-path');
const fs = require('mz/fs');
const async = require('async');
class Upload
@ -205,6 +206,40 @@ class UploadRepository
});
});
}
deleteExpired()
{
var self = this;
return new Promise((resolve, reject) =>
{
let now = new Date();
self.store.find({ $where: function() { return this.expirationDate !== null && this.expirationDate < now }}, (err, docs) =>
{
if (err)
{
reject(err);
return;
}
async.eachOfSeries(docs,
async (doc) =>
{
console.log('Expired upload: ' + doc._id);
await self.delete(doc._id);
},
(err) =>
{
if (err)
reject(err);
else
resolve();
});
});
});
}
}

View File

@ -0,0 +1,45 @@
class AbstractIntervalWorker
{
start(interval)
{
var self = this;
self.stop();
self.timer = setInterval(async () =>
{
if (self.ticking)
return;
self.ticking = true;
try
{
await self.tick();
}
catch (err)
{
console.log(err);
}
self.ticking = false;
}, interval);
}
stop()
{
var self = this;
if (self.timer)
{
clearInterval(self.timer);
self.timer = null;
}
}
/* Implement this:
async tick()
{
}
*/
}
module.exports = AbstractIntervalWorker;

23
lib/workers/expiration.js Normal file
View File

@ -0,0 +1,23 @@
const async = require('async');
const AbstractIntervalWorker = require('./abstractintervalworker');
class ExpirationWorker extends AbstractIntervalWorker
{
constructor(repository)
{
super();
this.repository = repository;
}
async tick()
{
var self = this;
await self.repository.uploads.deleteExpired();
await self.repository.codes.deleteExpired();
}
}
module.exports = ExpirationWorker;

View File

@ -1,45 +1,21 @@
const config = require('../../config');
const async = require('async');
const nodemailer = require('nodemailer');
const Email = require('email-templates');
const fs = require('mz/fs');
const path = require('path');
const getPaths = require('get-paths');
const AbstractIntervalWorker = require('./abstractintervalworker');
class NotificationWorker
class NotificationWorker extends AbstractIntervalWorker
{
constructor(repository)
{
super();
this.repository = repository;
}
start(interval)
{
var self = this;
self.stop();
self.timer = setInterval(async () =>
{
if (self.ticking)
return;
self.ticking = true;
await self.tick();
self.ticking = false;
}, interval);
}
stop()
{
var self = this;
if (self.timer)
{
clearInterval(self.timer);
self.timer = null;
}
}
async tick()
{
var self = this;
@ -88,6 +64,39 @@ class NotificationWorker
}
});
// Override the default template locator with one that checks
// the custom folder first, then falls back to the configured folder
let getTemplatePathFromRoot = function(root, view, ext)
{
return new Promise(async (resolve, reject) => {
try {
const paths = await getPaths(
root,
view,
ext
);
const filePath = path.resolve(root, paths.rel);
resolve({ filePath, paths });
} catch (err) {
reject(err);
}
});
};
email.getTemplatePath = async function(view)
{
try
{
return await getTemplatePathFromRoot(path.resolve('custom/emails'), view, this.config.views.options.extension);
}
catch (err)
{
return await getTemplatePathFromRoot(this.config.views.root, view, this.config.views.options.extension);
}
};
email
.send({
template: 'uploadnotification',

View File

@ -4,7 +4,7 @@
"description": "Recv - self-hosted web file transfer",
"main": "index.js",
"scripts": {
"dev": "supervisor -w index.js,lib index.js",
"dev": "supervisor -w index.js,lib,config.js,config.defaults.js index.js",
"devbuild": "webpack-cli --mode development",
"build": "webpack-cli --mode production",
"test": "echo \"Error: no test specified\" && exit 1"

View File

@ -53,6 +53,21 @@ Vue.filter('formatDateTime', (value) =>
}
});
Vue.filter('formatDate', (value) =>
{
if (value)
{
return moment(String(value)).format(i18n.t('dateFormat'))
}
});
Vue.filter('formatTime', (value) =>
{
if (value)
{
return moment(String(value)).format(i18n.t('timeFormat'))
}
});
// All credit goes to: https://stackoverflow.com/a/14919494
function humanFileSize(bytes, si)

View File

@ -2,6 +2,8 @@ export default {
title: 'File upload - Recv',
disclaimer: '',
dateTimeFormat: 'MM/DD/YYYY hh:mm a',
dateFormat: 'MM/DD/YYYY',
timeFormat: 'hh:mm a',
landing: {
invitePlaceholder: 'Code',
@ -16,7 +18,20 @@ export default {
invalidLogin: 'The specified username or password is incorrect'
},
messageFrom: 'Message from ',
messageFrom: 'Message from {owner}',
uploadDisclaimer: '',
expirationNotice: {
never: '',
ever: 'Files uploaded for this invite code will be automatically deleted on {date} at {time}'
},
expirationValues: {
h: '0 hours | 1 hour | {count} hours',
d: '0 days | 1 day | {count} days',
w: '0 weeks | 1 week | {count} weeks',
M: '0 months | 1 month | {count} months',
y: '0 years | 1 year | {count} years'
},
uppyDashboard: {
done: 'Done',
@ -33,7 +48,7 @@ export default {
save: 'Save',
delete: 'Delete',
diskspace: '%{available} disk space available of %{total} total',
diskspace: '{available} disk space available of {total} total',
login: {
usernamePlaceholder: 'Username or e-mail address',
@ -85,14 +100,7 @@ export default {
y: 'Years:'
},
expirationHint: {
format: 'The maximum allowed expiration is %{valueUnits}',
h: '0 hours | 1 hour | {count} hours',
d: '0 days | 1 day | {count} days',
w: '0 weeks | 1 week | {count} weeks',
M: '0 months | 1 month | {count} months',
y: '0 years | 1 year | {count} years'
}
expirationHint: 'The maximum allowed expiration is {valueUnits}'
}
},

View File

@ -2,6 +2,8 @@ export default {
title: 'Bestandsoverdracht - Recv',
disclaimer: '',
dateTimeFormat: 'DD-MM-YYYY HH:mm',
dateFormat: 'DD-MM-YYYY',
timeFormat: 'HH:mm',
landing: {
invitePlaceholder: 'Code',
@ -16,7 +18,20 @@ export default {
invalidLogin: 'De ingevoerde gebruikersnaam of wachtwoord is incorrect'
},
messageFrom: 'Bericht van ',
messageFrom: 'Bericht van {owner}',
uploadDisclaimer: '',
expirationNotice: {
never: '',
ever: 'Bestanden die worden geupload voor deze code worden automatisch verwijderd op {date} om {time}'
},
expirationValues: {
h: '0 uren | 1 uur | {count} uren',
d: '0 dagen | 1 dag | {count} dagen',
w: '0 weken | 1 week | {count} weken',
M: '0 maanden | 1 maand | {count} maanden',
y: '0 jaren | 1 jaar | {count} jaren'
},
uppyDashboard: {
done: 'Gereed',
@ -33,7 +48,7 @@ export default {
save: 'Opslaan',
delete: 'Verwijderen',
diskspace: '%{available} schijfruimte beschikbaar van %{total} totaal',
diskspace: '{available} schijfruimte beschikbaar van {total} totaal',
login: {
usernamePlaceholder: 'Gebruikersnaam of e-mail adres',
@ -85,14 +100,7 @@ export default {
y: 'Jaren:'
},
expirationHint: {
format: 'De maximaal toegestane verlooptermijn is %{valueUnits}',
h: '0 uren | 1 uur | {value} uren',
d: '0 dagen | 1 dag | {value} dagen',
w: '0 weken | 1 week | {value} weken',
M: '0 maanden | 1 maand | {value} maanden',
y: '0 jaren | 1 jaar | {value} jaren'
}
expirationHint: 'De maximaal toegestane verlooptermijn is {valueUnits}'
}
},

View File

@ -8,13 +8,15 @@
<router-view></router-view>
</div>
<div class="disclaimer">
<div class="disclaimer" v-if="$t('disclaimer')">
{{ $t('disclaimer') }}
</div>
</div>
</template>
<script>
export default {
<style lang="scss">
.disclaimer
{
margin-bottom: 2rem;
}
</script>
</style>

View File

@ -1,10 +1,19 @@
<template>
<div id="upload">
<div class="message" v-if="message !== null">
<div class="from" v-if="message.name !== null">{{ $t('messageFrom') + message.name }}</div>
<div class="from" v-if="message.name !== null">{{ $t('messageFrom', { owner: message.name }) }}</div>
<div v-html="message.message"></div>
</div>
<div class="uploadDisclaimer" v-if="$t('uploadDisclaimer')">
{{ $t('uploadDisclaimer') }}
</div>
<div class="expirationDate" v-if="showExpirationNotice()">
{{ expirationDate === null ? $t('expirationNotice.never') : $t('expirationNotice.ever', getExpirationNoticeDateTime()) }}
</div>
<div class="uploadTarget"></div>
<div class="navigation">
@ -30,7 +39,8 @@ export default {
data () {
return {
uploadToken: shared.uploadToken,
message: null
message: null,
expirationDate: null
}
},
@ -49,10 +59,11 @@ export default {
}
axios.get('/message/' + encodeURIComponent(self.codeParam))
axios.get('/info/' + encodeURIComponent(self.codeParam))
.then((response) =>
{
self.message = response.status !== 204 ? response.data : null;
self.message = response.data.message;
self.expirationDate = response.data.expirationDate !== null ? new Date(response.data.expirationDate) : null;
});
},
@ -119,6 +130,30 @@ export default {
uploadURL: false
})
});
},
methods: {
showExpirationNotice()
{
var self = this;
if (self.expirationDate === null)
return self.$i18n.t('expirationNotice.never') != '';
else
return self.$i18n.t('expirationNotice.ever') != '';
},
getExpirationNoticeDateTime()
{
var self = this;
return {
date: self.$options.filters.formatDate(self.expirationDate),
time: self.$options.filters.formatTime(self.expirationDate)
};
}
}
}
</script>
@ -131,11 +166,23 @@ export default {
color: #808080;
font-style: italic;
}
border-bottom: solid 1px #e0e0e0;
margin-bottom: 1rem;
}
.navigation
{
margin-top: 1rem;
text-align: center;
}
.uploadDisclaimer, .expirationDate
{
color: #808080;
font-size: 75%;
margin-bottom: 1rem;
}
</style>

View File

@ -47,7 +47,7 @@
<input v-if="code.id" id="expiration" type="text" readonly :value="code.expirationDate | formatDateTime" class="pure-input-2-3">
</div>
<div class="pure-form-description" v-if="!code.id && maxExpiration !== null">
{{ $t('admin.codes.detail.expirationHint.format', { valueUnits: getExpirationValueUnits() }) }}
{{ $t('admin.codes.detail.expirationHint', { valueUnits: getExpirationValueUnits() }) }}
</div>
<div class="pure-control-group">
@ -123,20 +123,26 @@ export default {
}
else
{
axios.get('/admin/maxExpiration', {
axios.get('/admin/expiration', {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
self.maxExpiration = response.data.units ? {
units: response.data.units,
value: response.data.value
self.maxExpiration = response.data.max !== null ? {
units: response.data.max.units,
value: response.data.max.value
} : null;
self.code = {
expiration: response.data,
expiration: response.data.default !== null ? {
units: response.data.default.units,
value: response.data.default.value
} : {
units: '',
value: 1
},
message: null
};
})
@ -197,7 +203,7 @@ export default {
return self.maxExpiration === null;
if (self.maxExpiration === null)
return false;
return true;
let validUnits = ['h', 'd', 'w', 'M', 'y'];
return validUnits.indexOf(units) <= validUnits.indexOf(self.maxExpiration.units);
@ -211,7 +217,7 @@ export default {
if (self.maxExpiration === null)
return '';
return self.$tc('admin.codes.detail.expirationHint.' + self.maxExpiration.units,
return self.$tc('expirationValues.' + self.maxExpiration.units,
self.maxExpiration.value,
{ count: self.maxExpiration.value });
}

View File

@ -75,11 +75,13 @@ export default {
{
var self = this;
self.unwatch = self.$watch(self.updateDiskSpace, () => self.updateDiskSpace());
self.refreshTimer = setInterval(() => self.updateDiskSpace(), 10000);
},
beforeDestroy()
{
var self = this;
clearInterval(self.refreshTimer);
self.unwatch();
},
@ -87,8 +89,10 @@ export default {
updateDiskSpace()
{
var self = this;
if (shared.adminToken !== null)
if (shared.adminToken !== null && !self.updatingDiskSpace)
{
self.updatingDiskSpace = true;
axios.get('/admin/diskspace', {
headers: {
Authorization: 'Bearer ' + shared.adminToken
@ -96,6 +100,13 @@ export default {
.then((response) =>
{
self.diskspace = response.data;
})
.catch((err) =>
{
})
.then(() =>
{
self.updatingDiskSpace = false;
});
}
}