Admin panel

Added adding and editing of codes
Added code description and message
Added file sizes
Added disk usage footer
This commit is contained in:
Mark van Renswoude 2018-04-27 17:13:18 +02:00
parent 5d454ea7c7
commit 45ae38bd66
22 changed files with 915 additions and 182 deletions

View File

@ -5,6 +5,7 @@ const jwt = require('jsonwebtoken');
const path = require('path');
const resolvePath = require('resolve-path');
const AuthTokens = require('../authtokens');
const disk = require('diskusage');
async function checkAuthorization(req, res, repository, onVerified)
@ -84,6 +85,27 @@ module.exports = (repository) =>
}));
router.get('/diskspace', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>
{
disk.check(config.fileUpload.path, (err, info) =>
{
if (err)
{
res.sendStatus(500);
return;
}
res.send(info);
});
});
}));
/*
Codes
*/
router.get('/codes', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>
@ -101,6 +123,68 @@ module.exports = (repository) =>
}));
router.get('/codes/:code', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>
{
var code = await repository.codes.getCode(req.params.code);
if (code === null || (code.userId !== user.userId && !user.hasAuth(AuthTokens.ViewAllCodes)))
{
res.sendStatus(404);
return;
}
var user = await repository.users.getUser(code.userId);
if (user !== null)
code.username = user.name;
res.send(code);
});
}));
router.post('/codes', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>
{
var postedCode = req.body;
if (postedCode.id)
{
var code = await repository.codes.getCode(postedCode.id);
if (code === null || (code.userId !== user.userId && !user.hasAuth(AuthTokens.ViewAllCodes)))
{
res.sendStatus(404);
return;
}
await repository.codes.updateCode({
id: postedCode.id,
expiration: postedCode.expiration,
description: postedCode.description,
message: postedCode.message
});
res.sendStatus(200);
}
else
{
var codeId = await repository.codes.addCode({
userId: user.id,
expiration: postedCode.expiration,
description: postedCode.description,
message: postedCode.message
});
}
res.send(codeId);
});
}));
/*
Uploads
*/
router.get('/uploads', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>

View File

@ -2,6 +2,9 @@ const config = require('../../config');
const express = require('express');
const asyncHandler = require('express-async-handler');
const jwt = require('jsonwebtoken');
const resolvePath = require('resolve-path');
const fs = require('fs');
const async = require('async');
async function checkAuthorization(req, res, onVerified)
@ -23,7 +26,7 @@ async function checkAuthorization(req, res, onVerified)
return;
}
if (decoded.codeUserId)
if (decoded.code)
await onVerified(decoded);
else
res.sendStatus(400);
@ -43,6 +46,30 @@ module.exports = (repository, tusServer) =>
var router = express.Router();
// Upload API
router.get('/message/:code', asyncHandler(async (req, res) =>
{
var code = await repository.codes.getCode(req.params.code);
if (code === null)
{
res.sendStatus(404);
return;
}
if (!code.messageHTML)
{
res.sendStatus(204);
return;
}
var user = await repository.users.getUser(code.userId);
var name = user !== null ? user.name : null;
res.send({
name: name,
message: code.messageHTML
});
}));
router.post('/complete', asyncHandler(async (req, res) =>
{
if (!req.body.files)
@ -54,8 +81,33 @@ module.exports = (repository, tusServer) =>
await checkAuthorization(req, res, async (decoded) =>
{
var expiration = null; // TODO set expiration properties
var uploadId = await repository.uploads.addUpload(decoded.codeUserId, req.body.files, expiration);
res.send({ id: uploadId });
async.each(req.body.files, (item, callback) =>
{
if (!item.id)
{
callback();
return;
}
var fullpath = resolvePath(config.fileUpload.path, item.id);
fs.stat(fullpath, (err, stats) =>
{
item.size = stats.size;
callback();
});
},
async (err) =>
{
if (err)
{
res.sendStatus(500);
return;
}
var uploadId = await repository.uploads.addUpload(decoded.codeUserId, decoded.code, req.body.files, expiration);
res.send({ id: uploadId });
});
});
}));

View File

@ -1,4 +1,10 @@
const _ = require('lodash');
const retry = require('async-retry');
const shortid = require('shortid');
const markdown = require('markdown').markdown;
shortid.characters('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-.');
class Code
@ -11,6 +17,9 @@ class Code
self.userId = values.userId || null;
self.created = values.created || new Date();
self.expiration = values.expiration || null;
self.description = values.description || null;
self.message = values.message || null;
self.messageHTML = values.messageHTML || null;
}
}
@ -30,20 +39,7 @@ class CodeRepository
return new Promise((resolve, reject) =>
{
// Initialize database if empty
self.store.count({}, (err, count) =>
{
if (err)
{
reject(err);
return;
}
if (count == 0)
self.addUser('admin', null, 'test', null);
resolve();
});
resolve();
});
}
@ -68,25 +64,28 @@ class CodeRepository
}
async addCode(userId, expiration)
async addCode(code)
{
var self = this;
return await retry(async bail =>
{
var code = shortid.generate();
var codeId = shortid.generate();
if ((await self.findCodeUserId(code)) !== null)
throw new Error('Code ' + code + ' already exists');
if ((await self.findCodeUserId(codeId)) !== null)
throw new Error('Code ' + codeId + ' already exists');
self.store.insert({
_id: code,
userId: userId,
created: new Date(),
expiration: expiration
_id: codeId,
userId: code.userId,
created: code.created || new Date(),
expiration: code.expiration,
description: code.description,
message: code.message,
messageHTML: self.getMessageHTML(code.message)
})
return code;
return codeId;
}, {
retries: 100,
minTimeout: 0,
@ -95,6 +94,37 @@ class CodeRepository
}
updateCode(code)
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.update({ _id: code.id }, { $set: {
expiration: code.expiration,
description: code.description,
message: code.message,
messageHTML: self.getMessageHTML(code.message)
}},
(err, numAffected) =>
{
if (err)
{
reject(err);
return;
}
if (numAffected == 0)
{
reject();
}
resolve();
});
});
}
getCodes(userId)
{
var self = this;
@ -113,6 +143,32 @@ class CodeRepository
});
});
}
getCode(codeId)
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.findOne({ _id: codeId }, (err, doc) =>
{
if (err)
{
reject(err);
return;
}
resolve(doc !== null ? new Code(doc) : null);
});
});
}
getMessageHTML(message)
{
return message !== null ? markdown.toHTML(message) : null;
}
}

View File

@ -3,8 +3,6 @@ const _ = require('lodash');
const path = require('path');
const mkdirp = require('mkdirp');
const Datastore = require('nedb');
const shortid = require('shortid');
const retry = require('async-retry');
const UserRepository = require('./user').UserRepository;
const CodeRepository = require('./code').CodeRepository;

View File

@ -49,7 +49,7 @@ class UploadRepository
}
addUpload(userId, files, expiration)
addUpload(userId, code, files, expiration)
{
var self = this;
@ -58,10 +58,11 @@ class UploadRepository
var upload = {
created: new Date(),
userId: userId,
code: code,
expiration: expiration,
files: _.map(_.filter(files,
(file) => file.hasOwnProperty('id') && file.hasOwnProperty('name')),
(file) => { return { id: file.id, name: file.name } })
(file) => { return { id: file.id, name: file.name, size: file.size } })
};
if (upload.files.length)

View File

@ -91,7 +91,7 @@ class UserRepository
if (doc == null)
{
resolve(false);
resolve(null);
return;
}
@ -121,13 +121,7 @@ class UserRepository
return;
}
if (doc == null)
{
resolve(false);
return;
}
resolve(new User(doc));
resolve(doc !== null ? new User(doc) : null);
});
});
}

111
package-lock.json generated
View File

@ -4,6 +4,32 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@fortawesome/fontawesome": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome/-/fontawesome-1.1.5.tgz",
"integrity": "sha512-WAgbcVs7/YTxq7RK/dhyoJPzaIZpOQnStyO5s1sj0rZa0J1ScXYoGPmsP1ec6qM/BhDjRVB228xr2DiCPTHRCA==",
"requires": {
"@fortawesome/fontawesome-common-types": "0.1.4"
}
},
"@fortawesome/fontawesome-common-types": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.1.4.tgz",
"integrity": "sha512-JEgIoqh9HAQWul8CVLrOmWUI8nUQfCJZygRJ1Cr2H/O3+pCpVszW199cIpc7k7Nr1HJtLD77AUZVnxaKllx7AQ=="
},
"@fortawesome/fontawesome-free-solid": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free-solid/-/fontawesome-free-solid-5.0.10.tgz",
"integrity": "sha512-RFxCG49xJcU7NwcEjAnJaerlzCzCX5sx9HdbHt3E1zML2skwmAZ2PwqyEfuT0iW2ZfYyx6SU1Zf0GUa7FL6f/A==",
"requires": {
"@fortawesome/fontawesome-common-types": "0.1.4"
}
},
"@fortawesome/vue-fontawesome": {
"version": "0.0.22",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-0.0.22.tgz",
"integrity": "sha512-KKQ1hVTWkXRaSGVMNI/dZsznVJIZ8dqLA2J6X8nZAIg7F2Gs1zIzBC9qxOKm+gktFAJU2aCCR7BTCoZIdjvGNw=="
},
"@sindresorhus/is": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz",
@ -309,10 +335,12 @@
"dev": true
},
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz",
"integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==",
"requires": {
"lodash": "4.17.5"
}
},
"async-each": {
"version": "1.0.1",
@ -2484,6 +2512,15 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
},
"cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha1-D+MfoZ0AC5X0qt8fU/3CuKIDuqU=",
"requires": {
"cookie": "0.3.1",
"cookie-signature": "1.0.6"
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -2912,6 +2949,14 @@
"randombytes": "2.0.6"
}
},
"diskusage": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/diskusage/-/diskusage-0.2.4.tgz",
"integrity": "sha512-XCLBopqnV6FUG/DdphILleiubqVERvF1ZRqkvOIiPQeMlU6Im1nvlsYqLUosgmRz1UQOXcwuO0vgqASl7DNg+w==",
"requires": {
"nan": "2.10.0"
}
},
"dom-converter": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.1.4.tgz",
@ -3642,6 +3687,16 @@
"escape-string-regexp": "1.0.5"
}
},
"file-loader": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.11.tgz",
"integrity": "sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==",
"dev": true,
"requires": {
"loader-utils": "1.1.0",
"schema-utils": "0.4.5"
}
},
"filename-regex": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz",
@ -6210,6 +6265,11 @@
"integrity": "sha512-H7ErYLM34CvDMto3GbD6xD0JLUGYXR3QTcH6B/tr4Hi/QpSThnCsIp+Sy5FRTw3B0d6py4HcNkW7nO/wdtGWEw==",
"dev": true
},
"js-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz",
"integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s="
},
"js-tokens": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
@ -6998,6 +7058,24 @@
"object-visit": "1.0.1"
}
},
"markdown": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/markdown/-/markdown-0.5.0.tgz",
"integrity": "sha1-KCBbVlqK51kt4gdGPWY33BgnIrI=",
"requires": {
"nopt": "2.1.2"
},
"dependencies": {
"nopt": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-2.1.2.tgz",
"integrity": "sha1-bMzZd7gBMqB3MdbozljCyDA8+a8=",
"requires": {
"abbrev": "1.1.1"
}
}
}
},
"math-clamp-x": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/math-clamp-x/-/math-clamp-x-1.2.0.tgz",
@ -7335,6 +7413,11 @@
"minimist": "0.0.8"
}
},
"moment": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz",
"integrity": "sha512-shJkRTSebXvsVqk56I+lkb2latjBs8I+pc2TzWc545y2iFnSjm7Wg0QMh+ZWcdSLQyGEau5jI8ocnmkyTgr9YQ=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -7381,8 +7464,7 @@
"nan": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
"dev": true
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA=="
},
"nan-x": {
"version": "1.0.2",
@ -13491,6 +13573,15 @@
"integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=",
"dev": true
},
"resolve-path": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz",
"integrity": "sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=",
"requires": {
"http-errors": "1.6.2",
"path-is-absolute": "1.0.1"
}
},
"resolve-url": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
@ -17497,6 +17588,14 @@
"recast": "0.12.9",
"temp": "0.8.3",
"write-file-atomic": "1.3.4"
},
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
}
},
"kind-of": {

View File

@ -15,17 +15,24 @@
"author": "Mark van Renswoude <mark@x2software.net>",
"license": "Unlicense",
"dependencies": {
"@fortawesome/fontawesome": "^1.1.5",
"@fortawesome/fontawesome-free-solid": "^5.0.10",
"@fortawesome/vue-fontawesome": "0.0.22",
"async": "^2.6.0",
"async-retry": "^1.2.1",
"bcrypt": "^1.0.3",
"body-parser": "^1.18.2",
"cookie-parser": "^1.4.3",
"debug": "^3.1.0",
"diskusage": "^0.2.4",
"express": "^4.16.3",
"express-async-handler": "^1.1.2",
"js-cookie": "^2.2.0",
"jsonwebtoken": "^8.2.0",
"lodash": "^4.17.5",
"markdown": "^0.5.0",
"mkdirp": "^0.5.1",
"moment": "^2.22.1",
"nedb": "^1.8.0",
"npm": "^5.8.0",
"resolve-path": "^1.4.0",
@ -37,6 +44,7 @@
"babel-loader": "^7.1.4",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"css-loader": "^0.28.11",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.1.0",
"node-sass": "^4.8.3",
"purecss": "^1.0.0",

View File

@ -16,6 +16,7 @@
import _ from 'lodash';
import shared from './shared';
export default {
name: 'app',
data() {
@ -65,10 +66,10 @@ export default {
</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';
@import '~purecss/build/base.css';
@import '~purecss/build/buttons.css';
@import '~purecss/build/forms.css';
@import '~purecss/build/grids.css';
/* open-sans-regular - latin */
@font-face {
@ -92,6 +93,15 @@ body
font-size: 12pt;
}
/*
pure-g uses hardcoded sans-serif to get accurate alignment, this class
is added as a workaround for text in rows
*/
.text
{
font-family: 'Open Sans', sans-serif;
}
.container
{
margin-left: auto;
@ -154,4 +164,13 @@ body
font-size: 8pt;
text-align: center;
}
.pure-form-description
{
font-size: 80%;
color: #808080;
margin-left: 180px;
margin-bottom: .5rem;
}
</style>

View File

@ -3,7 +3,11 @@ import VueI18n from 'vue-i18n';
import VueRouter from 'vue-router';
import App from './App.vue';
import messages from './lang';
import shared from './shared';
import fontawesome from '@fortawesome/fontawesome';
import FontAwesomeIcon from '@fortawesome/vue-fontawesome';
import solid from '@fortawesome/fontawesome-free-solid';
import moment from 'moment'
if (typeof customMessages !== 'undefined')
{
@ -20,6 +24,9 @@ if (typeof customMessages !== 'undefined')
}
fontawesome.library.add(solid);
Vue.component('fa', FontAwesomeIcon);
Vue.use(VueI18n);
Vue.use(VueRouter);
@ -30,7 +37,52 @@ const i18n = new VueI18n({
});
Vue.filter('formatDateTime', (value) =>
{
if (value)
{
return moment(String(value)).format(i18n.t('dateTimeFormat'))
}
});
// All credit goes to: https://stackoverflow.com/a/14919494
function humanFileSize(bytes, si)
{
var thresh = si ? 1000 : 1024;
if(Math.abs(bytes) < thresh)
{
return bytes + ' B';
}
var units = si
? ['kB','MB','GB','TB','PB','EB','ZB','YB']
: ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB'];
var u = -1;
do
{
bytes /= thresh;
++u;
}
while(Math.abs(bytes) >= thresh && u < units.length - 1);
return bytes.toFixed(1) + ' ' + units[u];
}
Vue.filter('formatSize', (value) =>
{
return humanFileSize(value, false);
});
Vue.filter('formatSizeSI', (value) =>
{
return humanFileSize(value, true);
});
const Code = () => import('./route/Code.vue');
const AdminCodeDetail = () => import('./route/admin/CodeDetail.vue');
const router = new VueRouter({
@ -45,6 +97,8 @@ const router = new VueRouter({
{ path: '/admin', component: () => import('./route/admin/Landing.vue'),
children: [
{ path: 'uploads', component: () => import('./route/admin/Uploads.vue') },
{ path: 'codes/add', component: AdminCodeDetail },
{ path: 'codes/edit/:codeParam', component: AdminCodeDetail, props: true },
{ path: 'codes', component: () => import('./route/admin/Codes.vue') },
{ path: 'profile', component: () => import('./route/admin/Profile.vue') },
{ path: 'users', component: () => import('./route/admin/Users.vue') },
@ -55,6 +109,22 @@ const router = new VueRouter({
]
});
router.beforeEach((to, from, next) =>
{
if (to.path.startsWith('/admin/') && to.path != '/admin/')
{
if (!shared.adminToken)
{
router.push('/admin');
return;
}
}
next();
});
new Vue({
el: '#app',
i18n,

View File

@ -1,6 +1,7 @@
export default {
title: 'File upload - Recv',
disclaimer: '',
dateTimeFormat: 'MM/DD/YYYY hh:mm a',
landing: {
invitePlaceholder: 'Code',
@ -11,9 +12,12 @@ export default {
},
notification: {
invalidCode: 'The specified code is invalid or has expired'
invalidCode: 'The specified code is invalid or has expired',
invalidLogin: 'The specified username or password is incorrect'
},
messageFrom: 'Message from ',
uppyDashboard: {
done: 'Done',
dropPaste: 'Drop files here, paste or',
@ -25,6 +29,10 @@ export default {
loading: 'Loading…',
empty: 'No data',
logout: 'Logout',
cancel: 'Cancel',
save: 'Save',
diskspace: '%{available} disk space available of %{total} total',
login: {
usernamePlaceholder: 'Username or e-mail address',
@ -41,14 +49,28 @@ export default {
uploads: {
created: 'Date',
code: 'Code',
owner: 'Owner'
},
codes: {
code: 'Code',
owner: 'Owner',
add: 'Generate code',
add: 'Generate code'
list: {
code: 'Code',
owner: 'Owner'
},
detail: {
code: 'Code',
owner: 'Owner',
created: 'Date created',
description: 'Description',
descriptionHint: 'The description will be visible only in the admin panel',
expiration: 'Expiration',
message: 'Message',
messageHint: 'The message will be displayed to the user on the upload page after the code is entered. Markdown is supported.'
}
}
}
}

View File

@ -1,6 +1,7 @@
export default {
title: 'Bestandsoverdracht - Recv',
disclaimer: '',
dateTimeFormat: 'DD-MM-YYYY HH:mm',
landing: {
invitePlaceholder: 'Code',
@ -11,9 +12,12 @@ export default {
},
notification: {
invalidCode: 'De ingevoerde code is ongeldig of verlopen'
invalidCode: 'De ingevoerde code is ongeldig of verlopen',
invalidLogin: 'De ingevoerde gebruikersnaam of wachtwoord is incorrect'
},
messageFrom: 'Bericht van ',
uppyDashboard: {
done: 'Gereed',
dropPaste: 'Sleep bestanden, plak of ',
@ -25,6 +29,10 @@ export default {
loading: 'Bezig met laden…',
empty: 'Geen gegevens',
logout: 'Uitloggen',
cancel: 'Annuleren',
save: 'Opslaan',
diskspace: '%{available} schijfruimte beschikbaar van %{total} totaal',
login: {
usernamePlaceholder: 'Gebruikersnaam of e-mail adres',
@ -41,14 +49,28 @@ export default {
uploads: {
created: 'Datum',
code: 'Code',
owner: 'Eigenaar'
},
codes: {
code: 'Code',
owner: 'Eigenaar',
add: 'Genereer code',
add: 'Genereer code'
list: {
code: 'Code',
owner: 'Eigenaar'
},
detail: {
code: 'Code',
owner: 'Eigenaar',
created: 'Datum aangemaakt',
description: 'Omschrijving',
descriptionHint: 'De omschrijving is alleen zichtbaar in beheer',
expiration: 'Verloopt',
message: 'Bericht',
messageHint: 'Het bericht wordt getoond aan de gebruiker op de upload pagina na het invoeren van de code. Markdown kan worden gebruikt.'
}
}
}
}

View File

@ -52,7 +52,7 @@ export default {
})
.then((response) =>
{
shared.token = response.data;
shared.uploadToken = response.data;
self.$router.push({ path: '/u/' + self.code.value });
})
.catch((error) =>
@ -72,7 +72,7 @@ export default {
</script>
<style lang="scss">
@import '../../../node_modules/uppy/src/scss/uppy.scss';
@import '~uppy/src/scss/uppy.scss';
#landing

View File

@ -1,7 +1,15 @@
<template>
<div id="upload">
<router-link to="/">Terug</router-link>
<div class="message" v-if="message !== null">
<div class="from" v-if="message.name !== null">{{ $t('messageFrom') + message.name }}</div>
<div v-html="message.message"></div>
</div>
<div class="uploadTarget"></div>
<div class="navigation">
<router-link to="/" class="pure-button">Terug</router-link>
</div>
</div>
</template>
@ -21,7 +29,8 @@ export default {
data () {
return {
token: shared.token
uploadToken: shared.uploadToken,
message: null
}
},
@ -29,7 +38,7 @@ export default {
{
var self = this;
if (!self.token)
if (!self.uploadToken)
{
if (self.codeParam)
self.$router.push('/c/' + self.codeParam);
@ -38,6 +47,13 @@ export default {
return;
}
axios.get('/message/' + encodeURIComponent(self.codeParam))
.then((response) =>
{
self.message = response.status !== 204 ? response.data : null;
});
},
mounted()
@ -64,7 +80,7 @@ export default {
.use(Tus, {
endpoint: '/upload/',
headers: {
Authorization: 'Bearer ' + self.token
Authorization: 'Bearer ' + self.uploadToken
}
})
.run();
@ -82,7 +98,7 @@ export default {
},
{
headers: {
Authorization: 'Bearer ' + self.token
Authorization: 'Bearer ' + self.uploadToken
}
})
.then((response) =>
@ -92,6 +108,7 @@ export default {
.catch((error) =>
{
// TODO can we convince Uppy that the files actually failed?
shared.$emit('showNotification', error.message);
});
});
@ -104,4 +121,21 @@ export default {
});
}
}
</script>
</script>
<style lang="scss">
.message
{
.from
{
color: #808080;
font-style: italic;
}
}
.navigation
{
margin-top: 1rem;
text-align: center;
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<div id="code">
<div v-if="code !== null">
<form class="pure-form pure-form-aligned">
<fieldset>
<div class="pure-control-group" v-if="code.id">
<label for="codeId">{{ $t('admin.codes.detail.code') }}</label>
<input id="codeId" type="text" readonly :value="code.id" class="pure-input-2-3">
</div>
<div class="pure-control-group" v-if="code.id">
<label for="user">{{ $t('admin.codes.detail.owner') }}</label>
<input id="user" type="text" readonly :value="code.username" class="pure-input-2-3">
</div>
<div class="pure-control-group" v-if="code.id">
<label for="created">{{ $t('admin.codes.detail.created') }}</label>
<input id="created" type="text" readonly :value="code.created | formatDateTime" class="pure-input-2-3">
</div>
<div class="pure-control-group">
<label for="description">{{ $t('admin.codes.detail.description') }}</label>
<input id="description" type="text" v-model="code.description" class="pure-input-2-3">
</div>
<div class="pure-form-description">
{{ $t('admin.codes.detail.descriptionHint' )}}
</div>
<!--
<div class="pure-control-group">
<label for="expiration">{{ $t('admin.codes.detail.expiration') }}</label>
<input id="expiration" type="text" :value="code.expiration" class="pure-input-2-3">
</div>
-->
<div class="pure-control-group">
<label for="message">{{ $t('admin.codes.detail.message') }}</label>
<textarea id="message" type="text" v-model="code.message" class="pure-input-2-3" rows="10"></textarea>
</div>
<div class="pure-form-description">
{{ $t('admin.codes.detail.messageHint' )}}
</div>
<div class="pure-controls">
<button class="pure-button pure-button-primary" @click="save" :disabled="saving">{{ $t('admin.save') }} <fa icon="spinner" pulse v-if="saving"></fa></button>
<router-link to="/admin/codes" class="pure-button">{{ $t('admin.cancel') }}</router-link>
</div>
</fieldset>
</form>
</div>
</div>
</template>
<script>
import axios from 'axios';
import shared from '../../shared';
export default {
data()
{
return {
code: null,
saving: false
};
},
props: ['codeParam'],
created()
{
var self = this;
if (self.codeParam)
{
axios.get('/admin/codes/' + encodeURIComponent(self.codeParam), {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
self.code = response.data;
})
.catch((error) => { shared.$emit('apiError', error, this.$router) });
}
else
{
self.code = {
expiration: null,
message: null
};
}
},
methods: {
save()
{
var self = this;
self.saving = true;
axios.post('/admin/codes', {
id: self.code.id,
expiration: self.code.expiration,
description: self.code.description,
message: self.code.message
}, {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
this.$router.push('/admin/codes');
})
.catch((error) => { shared.$emit('apiError', error, this.$router) })
.then(() =>
{
self.saving = false;
});
}
}
}
</script>
<style lang="scss">
</style>

View File

@ -1,38 +1,34 @@
<template>
<div id="codes">
<Menu></Menu>
<router-link to="codes/add" class="pure-button pure-button-primary">{{ $t('admin.codes.add') }}</router-link>
<div class="page">
<router-link to="codes/add" class="pure-button pure-button-primary">{{ $t('admin.codes.add') }}</router-link>
<div v-if="codes !== null">
<div class="pure-g list-header">
<div class="pure-u-3-4"><span class="text">{{ $t('admin.codes.list.code') }}</span></div>
<div class="pure-u-1-4"><span class="text">{{ $t('admin.codes.list.owner') }}</span></div>
</div>
<div v-if="codes !== null">
<div class="pure-g list-header">
<div class="pure-u-3-4">{{ $t('admin.codes.code') }}</div>
<div class="pure-u-1-4">{{ $t('admin.codes.owner') }}</div>
</div>
<div v-for="code in codes" class="list">
<div class="pure-g row">
<div class="pure-u-3-4">{{ code.id }}</div>
<div class="pure-u-1-4">{{ code.username }}</div>
</div>
</div>
<div v-if="codes.length == 0" class="nodata">
{{ $t('admin.empty') }}
<div v-for="code in codes" class="list">
<div class="pure-g row">
<div class="pure-u-3-4"><span class="text"><router-link :to="'codes/edit/' + encodeURIComponent(code.id)">{{ code.id }}</router-link></span></div>
<div class="pure-u-1-4"><span class="text">{{ code.username }}</span></div>
<div class="pure-u-1-1" v-if="code.description"><span class="text description">{{ code.description }}</span></div>
</div>
</div>
<div v-else class="loading">
{{ $t('admin.loading') }}
<div v-if="codes.length == 0" class="nodata">
{{ $t('admin.empty') }}
</div>
</div>
<div v-else class="loading">
{{ $t('admin.loading') }}
</div>
</div>
</template>
<script>
import axios from 'axios';
import Menu from './Menu.vue';
import shared from '../../shared';
export default {
@ -43,43 +39,28 @@ export default {
};
},
components: {
Menu
},
created()
{
var self = this;
if (!shared.token)
{
self.$router.push('/admin');
return;
}
axios.get('/admin/codes', {
headers: {
Authorization: 'Bearer ' + shared.token
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
self.codes = response.data;
self.codes = _.orderBy(response.data, ['created'], ['desc']);
})
.catch((error) =>
{
if (error.response && error.response.status == 403)
{
shared.token = null;
self.$router.push('/admin');
}
else
shared.$emit('showNotification', error.message);
});
.catch((error) => { shared.$emit('apiError', error, this.$router) });
}
}
</script>
<style lang="scss">
.description
{
font-size: 75%;
color: #808080;
}
</style>

View File

@ -1,18 +1,113 @@
<template>
<div id="admin">
<router-view></router-view>
<div v-if="loggedIn">
<Menu></Menu>
<div class="content">
<router-view></router-view>
</div>
</div>
<router-view v-else></router-view>
<div class="footer" v-if="loggedIn">
<div v-if="diskspace !== null">
{{ $t('admin.diskspace', formattedDiskspace) }}
<div class="diskspace">
<div class="bar" :class="{ warning: diskspaceWarning}" :style="'width: ' + diskspacePercentage + '%'"></div>
</div>
</div>
</div>
</div>
</template>
<script>
import Menu from './Menu.vue';
import shared from '../../shared';
import axios from 'axios';
export default {
data()
{
return {
diskspace: null
}
},
components: {
Menu
},
computed: {
loggedIn()
{
return shared.adminToken !== null;
},
formattedDiskspace()
{
var self = this;
if (self.diskspace === null)
return null;
return {
free: self.$options.filters.formatSizeSI(self.diskspace.free),
available: self.$options.filters.formatSizeSI(self.diskspace.available),
total: self.$options.filters.formatSizeSI(self.diskspace.total)
}
},
diskspacePercentage()
{
var self = this;
return Math.floor((self.diskspace.total - self.diskspace.available) / self.diskspace.total * 100);
},
diskspaceWarning()
{
var self = this;
return self.diskspacePercentage >= 80;
}
},
created()
{
var self = this;
self.unwatch = self.$watch(self.updateDiskSpace, () => self.updateDiskSpace());
},
beforeDestroy()
{
var self = this;
self.unwatch();
},
methods: {
updateDiskSpace()
{
var self = this;
if (shared.adminToken !== null)
{
axios.get('/admin/diskspace', {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}})
.then((response) =>
{
self.diskspace = response.data;
});
}
}
}
}
</script>
<style lang="scss">
.page
#admin .content
{
margin-top: 3rem;
margin-left: 0;
margin-right: 0;
}
.nodata
@ -30,7 +125,7 @@ $list-padding: .2rem;
.list-header
{
background-color: #e0e0e0;
background-color: #f0f0f0;
font-weight: bold;
margin-top: 1rem;
@ -45,4 +140,51 @@ $list-padding: .2rem;
padding: $list-padding;
}
}
.footer
{
font-size: 75%;
text-align: center;
margin-top: 3rem;
margin-bottom: 1rem;
}
.diskspace
{
border: solid 1px black;
width: 350px;
margin-left: auto;
margin-right: auto;
height: 21px;
/* Thanks http://www.colorzilla.com/gradient-editor/ ! */
background: #f6f8f9;
background: -moz-linear-gradient(top, #f6f8f9 0%, #e5ebee 50%, #d7dee3 51%, #f5f7f9 100%);
background: -webkit-linear-gradient(top, #f6f8f9 0%,#e5ebee 50%,#d7dee3 51%,#f5f7f9 100%);
background: linear-gradient(to bottom, #f6f8f9 0%,#e5ebee 50%,#d7dee3 51%,#f5f7f9 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f6f8f9', endColorstr='#f5f7f9',GradientType=0 );
.bar
{
height: 21px;
background: #b7deed;
background: -moz-linear-gradient(top, #b7deed 0%, #71ceef 50%, #21b4e2 51%, #b7deed 100%);
background: -webkit-linear-gradient(top, #b7deed 0%,#71ceef 50%,#21b4e2 51%,#b7deed 100%);
background: linear-gradient(to bottom, #b7deed 0%,#71ceef 50%,#21b4e2 51%,#b7deed 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#b7deed', endColorstr='#b7deed',GradientType=0 );
&.warning
{
background: #feccb1;
background: -moz-linear-gradient(top, #feccb1 0%, #f17432 50%, #ea5507 51%, #fb955e 100%);
background: -webkit-linear-gradient(top, #feccb1 0%,#f17432 50%,#ea5507 51%,#fb955e 100%);
background: linear-gradient(to bottom, #feccb1 0%,#f17432 50%,#ea5507 51%,#fb955e 100%);
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#feccb1', endColorstr='#fb955e',GradientType=0 );
}
}
}
</style>

View File

@ -12,7 +12,7 @@
<input type="password" class="pure-input-1-2" v-model="password" :placeholder="$t('admin.login.passwordPlaceholder')">
</fieldset>
<button type="submit" class="pure-button pure-button-primary" :disabled="username.trim() == '' || password.trim() == '' || checking">{{ $t(checking ? 'admin.login.buttonChecking' : 'admin.login.button') }} <span v-if="checking"><i class="fas fa-spinner fa-pulse"></i></span></button>
<button type="submit" class="pure-button pure-button-primary" :disabled="username.trim() == '' || password.trim() == '' || checking">{{ $t(checking ? 'admin.login.buttonChecking' : 'admin.login.button') }} <fa icon="spinner" pulse v-if="checking"></fa></button>
</form>
</div>
</div>
@ -48,13 +48,13 @@ export default {
})
.then((response) =>
{
shared.token = response.data;
shared.adminToken = response.data;
self.$router.push({ path: '/admin/uploads' });
})
.catch((error) =>
{
if (error.response && error.response.status == 403)
shared.$emit('showNotification', self.$i18n.t('notification.invalidCode'), false);
shared.$emit('showNotification', self.$i18n.t('notification.invalidLogin'), false);
else
shared.$emit('showNotification', error.message);
})

View File

@ -41,23 +41,21 @@ export default {
methods: {
hasAuth(token)
{
return shared.user.auth.indexOf(token) > -1;
return shared.user !== null && shared.user.auth.indexOf(token) > -1;
},
logout()
{
var self = this;
shared.token = null;
self.$router.push('/admin');
shared.adminToken = null;
}
}
}
</script>
<style lang="scss">
@import '../../../../node_modules/purecss/build/menus.css';
@import '~purecss/build/menus.css';
#menu

View File

@ -1,44 +1,44 @@
<template>
<div id="uploads">
<Menu></Menu>
<div v-if="uploads !== null">
<div class="pure-g list-header">
<div class="pure-u-1-3"><span class="text">{{ $t('admin.uploads.created') }}</span></div>
<div class="pure-u-1-3"><span class="text">{{ $t('admin.uploads.code') }}</span></div>
<div class="pure-u-1-3"><span class="text">{{ $t('admin.uploads.owner') }}</span></div>
</div>
<div class="page">
<div v-if="uploads !== null">
<div class="pure-g list-header">
<div class="pure-u-3-4">{{ $t('admin.uploads.created') }}</div>
<div class="pure-u-1-4">{{ $t('admin.uploads.owner') }}</div>
</div>
<div v-for="upload in uploads" class="list">
<div class="pure-g row">
<div class="pure-u-3-4">{{ upload.created }}</div>
<div class="pure-u-1-4">{{ upload.username }}</div>
<div class="pure-u-1-1">
<div class="file" v-for="file in upload.files" :title="file.name">
<a :href="getDownloadUrl(file)">
<img :src="getFileIconUrl(file.name)" class="icon">
<span class="filename">{{ file.name }}</span>
</a>
</div>
</div>
<div v-for="upload in uploads" class="list">
<div class="properties">
<div class="pure-g">
<div class="pure-u-1-3"><span class="text">{{ upload.created | formatDateTime }}</span></div>
<div class="pure-u-1-3"><span class="text">{{ upload.code }}</span></div>
<div class="pure-u-1-3"><span class="text">{{ upload.username }}</span></div>
</div>
</div>
<div v-if="uploads.length == 0" class="nodata">
{{ $t('admin.empty') }}
<div class="file" v-for="file in upload.files" :title="file.name">
<a :href="getDownloadUrl(file)">
<img :src="getFileIconUrl(file.name)" class="icon">
<span class="filename">{{ file.name }}</span>
<span class="size">{{ file.size | formatSizeSI }}</span>
</a>
</div>
</div>
<div v-else class="loading">
{{ $t('admin.loading') }}
<div v-if="uploads.length == 0" class="nodata">
{{ $t('admin.empty') }}
</div>
</div>
<div v-else class="loading">
{{ $t('admin.loading') }}
</div>
</div>
</template>
<script>
import _ from 'lodash';
import axios from 'axios';
import Menu from './Menu.vue';
import shared from '../../shared';
export default {
@ -49,15 +49,11 @@ export default {
};
},
components: {
Menu
},
created()
{
var self = this;
if (!shared.token)
if (!shared.adminToken)
{
self.$router.push('/admin');
return;
@ -66,23 +62,14 @@ export default {
axios.get('/admin/uploads', {
headers: {
Authorization: 'Bearer ' + shared.token
Authorization: 'Bearer ' + shared.adminToken
}
})
.then((response) =>
{
self.uploads = response.data;
self.uploads = _.orderBy(response.data, ['created'], ['desc']);
})
.catch((error) =>
{
if (error.response && error.response.status == 403)
{
shared.token = null;
self.$router.push('/admin');
}
else
shared.$emit('showNotification', error.message);
});
.catch((error) => { shared.$emit('apiError', error, this.$router) });
},
methods: {
@ -117,6 +104,8 @@ export default {
display: inline-block;
width: 10rem;
padding: .5rem;
margin-top: .5rem;
border: solid 1px transparent;
font-size: 75%;
@ -126,6 +115,12 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
a
{
color: black;
text-decoration: none;
}
.icon
{
display: block;
@ -133,5 +128,30 @@ export default {
margin-left: auto;
margin-right: auto;
}
.filename, .size
{
display: block;
}
.size
{
color: #808080;
}
&:hover
{
background-color: #d3e9f8;
border: solid 1px #a7d3f1;
}
}
.properties
{
background-color: #f4f4f4;
padding: .2rem;
}
</style>

View File

@ -1,25 +1,15 @@
<template>
<div id="users">
<Menu></Menu>
<div class="page">
Users!
</div>
Users!
</div>
</template>
<script>
import Menu from './Menu.vue';
export default {
data()
{
return {
};
},
components: {
Menu
}
}
</script>

View File

@ -6,8 +6,9 @@ import axios from 'axios';
export default new Vue({
data() {
return {
token: null,
user: []
uploadToken: null,
adminToken: null,
user: null
}
},
@ -15,32 +16,47 @@ export default new Vue({
{
var self = this;
var cookie = Cookies.get('token');
var cookie = Cookies.get('adminToken');
if (typeof cookie !== 'undefined')
self.token = cookie;
self.adminToken = cookie;
self.$on('apiError', (error, $router) =>
{
if (error.response && error.response.status == 403)
{
self.adminToken = null;
$router.push('/admin');
}
else
self.$emit('showNotification', error.message);
});
},
watch: {
token(newValue)
adminToken(newValue)
{
var self = this;
if (newValue !== null)
{
Cookies.set('token', newValue);
Cookies.set('adminToken', newValue);
axios.get('/admin/whoami', {
headers: {
Authorization: 'Bearer ' + self.token
Authorization: 'Bearer ' + self.adminToken
}})
.then((response) =>
{
self.user = response.data;
})
.catch((error) =>
{
});
}
else
{
Cookies.remove('token');
Cookies.remove('adminToken');
self.user = null;
}
}