Changed to nanoid for more flexible code generation

Added deleting of uploads
Adding a new code now goes into edit mode after applying, so you can copy the code directly
This commit is contained in:
Mark van Renswoude 2018-04-28 09:24:53 +02:00
parent 45ae38bd66
commit 6f827382ff
17 changed files with 335 additions and 67 deletions

View File

@ -15,5 +15,12 @@ module.exports = {
url: '/files'
},
jwtSecret: 'change me to a random generated string'
jwtSecret: 'change me to a random generated string',
code: {
// Use https://alex7kom.github.io/nano-nanoid-cc/ to check for the chance of collisions.
// If a collision occurs, a new code will be generated and tested again for up to 100 times.
alphabet: '1234567890abcdef',
length: 8
}
};

View File

@ -6,6 +6,7 @@ const path = require('path');
const resolvePath = require('resolve-path');
const AuthTokens = require('../authtokens');
const disk = require('diskusage');
const fs = require('mz/fs');
async function checkAuthorization(req, res, repository, onVerified)
@ -111,7 +112,7 @@ module.exports = (repository) =>
await checkAuthorization(req, res, repository, async (user) =>
{
var codes = await repository.codes.getCodes(user.hasAuth(AuthTokens.ViewAllCodes) ? null : user.userId);
var usernames = await repository.users.getUserNames();
var usernames = await repository.users.getNames();
codes.forEach((item) =>
{
@ -190,11 +191,13 @@ module.exports = (repository) =>
await checkAuthorization(req, res, repository, async (user) =>
{
var files = await repository.uploads.getUploads(user.hasAuth(AuthTokens.ViewAllUploads) ? null : user.userId);
var usernames = await repository.users.getUserNames();
var usernames = await repository.users.getNames();
var codedescriptions = await repository.codes.getDescriptions();
files.forEach((item) =>
{
item.username = usernames[item.userId];
item.username = item.userId !== null ? usernames[item.userId] : null;
item.codedescription = item.code !== null ? codedescriptions[item.code] : null;
});
res.send(files);
@ -202,6 +205,41 @@ module.exports = (repository) =>
}));
router.delete('/uploads/:id', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>
{
var upload = await repository.uploads.getUpload(req.params.id);
if (upload == null || (upload.userId !== user.userId && !user.hasAuth(AuthTokens.ViewAllUploads)))
{
res.sendStatus(404);
return;
}
repository.uploads.delete(upload.id);
await Promise.all(upload.files.map(async (file) =>
{
if (!file.id) return;
if (!(await repository.uploads.fileExists(file.id)))
{
var fullpath = resolvePath(config.fileUpload.path, file.id);
try
{
await fs.unlink(fullpath);
}
catch (err)
{
console.log('Failed to delete ' + fullpath);
}
}
}));
res.sendStatus(200);
});
}));
router.get('/download/:fileid/:displayname', asyncHandler(async (req, res) =>
{
await checkAuthorization(req, res, repository, async (user) =>

View File

@ -1,10 +1,8 @@
const _ = require('lodash');
const retry = require('async-retry');
const shortid = require('shortid');
const generate = require('nanoid/generate');
const markdown = require('markdown').markdown;
shortid.characters('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-.');
const config = require('../../config');
class Code
@ -70,7 +68,7 @@ class CodeRepository
return await retry(async bail =>
{
var codeId = shortid.generate();
var codeId = generate(config.code.alphabet, config.code.length);
if ((await self.findCodeUserId(codeId)) !== null)
throw new Error('Code ' + codeId + ' already exists');
@ -145,6 +143,33 @@ class CodeRepository
}
getDescriptions()
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.find({}, (err, docs) =>
{
if (err)
{
reject(err);
return;
}
var descriptions = {};
docs.forEach((dbCode) =>
{
if (dbCode.description)
descriptions[dbCode._id] = dbCode.description;
});
resolve(descriptions);
});
});
}
getCode(codeId)
{
var self = this;

View File

@ -9,6 +9,7 @@ class Upload
self.id = values.id || values._id || null;
self.userId = values.userId || null;
self.code = values.code || null;
self.created = values.created || new Date();
self.expiration = values.expiration || null;
self.files = values.files || [];
@ -107,6 +108,66 @@ class UploadRepository
});
});
}
getUpload(uploadId)
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.findOne({ _id: uploadId }, (err, doc) =>
{
if (err)
{
reject(err);
return;
}
resolve(doc !== null ? new Upload(doc) : null);
});
});
}
delete(uploadId)
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.remove({ _id: uploadId }, (err, numRemoved) =>
{
if (err)
{
reject(err);
return;
}
resolve();
});
});
}
fileExists(fileId)
{
var self = this;
return new Promise((resolve, reject) =>
{
self.store.findOne({ 'files.id': fileId }, (err, doc) =>
{
if (err)
{
reject(err);
return;
}
resolve(doc !== null);
});
});
}
}

View File

@ -165,7 +165,7 @@ class UserRepository
}
getUserNames()
getNames()
{
var self = this;

41
package-lock.json generated
View File

@ -184,6 +184,11 @@
"integrity": "sha1-xnhwBYADV5AJCD9UrAq6+1wz0kI=",
"dev": true
},
"any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8="
},
"anymatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
@ -7455,6 +7460,16 @@
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
"dev": true
},
"mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"requires": {
"any-promise": "1.3.0",
"object-assign": "4.1.1",
"thenify-all": "1.6.0"
}
},
"namespace-emitter": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
@ -7472,6 +7487,11 @@
"integrity": "sha512-dndRmy03JQEN+Nh6WjQl7/OstIozeEmrtWe4TE7mEqJ8W8oMD8m2tHjsLPWt//e3hLAeRSbs4pxMyc5pk/nCkQ==",
"dev": true
},
"nanoid": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-1.0.2.tgz",
"integrity": "sha512-sCTwJt690lduNHyqknXJp8pRwzm80neOLGaiTHU2KUJZFVSErl778NNCIivEQCX5gNT0xR1Jy3HEMe/TABT6lw=="
},
"nanomatch": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.9.tgz",
@ -13912,11 +13932,6 @@
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
"dev": true
},
"shortid": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.8.tgz",
"integrity": "sha1-AzsRfWoul1gE9vCWnb59PQs1UTE="
},
"signal-exit": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
@ -14605,6 +14620,22 @@
"integrity": "sha512-j5EMxnryTvKxwH2Cq+Pb43tsf6sdEgw6Pdwxk83mPaq0ToeFJt6WE4J3s5BqY7vmjlLgkgXvhtXUxo80FyBhCA==",
"dev": true
},
"thenify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz",
"integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=",
"requires": {
"any-promise": "1.3.0"
}
},
"thenify-all": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=",
"requires": {
"thenify": "3.3.0"
}
},
"through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",

View File

@ -33,10 +33,11 @@
"markdown": "^0.5.0",
"mkdirp": "^0.5.1",
"moment": "^2.22.1",
"mz": "^2.7.0",
"nanoid": "^1.0.2",
"nedb": "^1.8.0",
"npm": "^5.8.0",
"resolve-path": "^1.4.0",
"shortid": "^2.2.8",
"tus-node-server": "^0.2.10"
},
"devDependencies": {

View File

@ -93,6 +93,16 @@ body
font-size: 12pt;
}
a, a:hover
{
text-decoration: none;
}
a
{
color: #228dd4;
}
/*
pure-g uses hardcoded sans-serif to get accurate alignment, this class
is added as a workaround for text in rows

View File

@ -96,13 +96,13 @@ const router = new VueRouter({
},
{ path: '/admin', component: () => import('./route/admin/Landing.vue'),
children: [
{ path: 'uploads', component: () => import('./route/admin/Uploads.vue') },
{ path: 'uploads', name: 'adminDefault', 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') },
{ path: '', component: () => import('./route/admin/Login.vue') }
{ path: '', name: 'adminRoot', component: () => import('./route/admin/Login.vue') }
]
},
{ path: '*', redirect: '/' }
@ -112,14 +112,21 @@ const router = new VueRouter({
router.beforeEach((to, from, next) =>
{
if (to.path.startsWith('/admin/') && to.path != '/admin/')
let isAdminRoot = to.name == 'adminRoot';
if (to.path.startsWith('/admin/') && !isAdminRoot)
{
if (!shared.adminToken)
{
router.push('/admin');
router.push({ name: 'adminRoot' });
return;
}
}
else if (isAdminRoot && shared.adminToken)
{
router.push({ name: 'adminDefault' });
return;
}
next();
});

View File

@ -31,6 +31,7 @@ export default {
logout: 'Logout',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
diskspace: '%{available} disk space available of %{total} total',
@ -63,6 +64,7 @@ export default {
detail: {
code: 'Code',
codeHint: 'Direct link to the upload page for this code:',
owner: 'Owner',
created: 'Date created',
description: 'Description',

View File

@ -31,6 +31,7 @@ export default {
logout: 'Uitloggen',
cancel: 'Annuleren',
save: 'Opslaan',
delete: 'Verwijderen',
diskspace: '%{available} schijfruimte beschikbaar van %{total} totaal',
@ -63,6 +64,7 @@ export default {
detail: {
code: 'Code',
codeHint: 'Directe link naar de upload pagina voor deze code:',
owner: 'Eigenaar',
created: 'Datum aangemaakt',
description: 'Omschrijving',

View File

@ -7,6 +7,10 @@
<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-form-description" v-if="code.id">
<div>{{ $t('admin.codes.detail.codeHint') }}</div>
<a :href="getCodeUrl(code.id)" target="_blank">{{ getCodeUrl(code.id) }}</a>
</div>
<div class="pure-control-group" v-if="code.id">
<label for="user">{{ $t('admin.codes.detail.owner') }}</label>
@ -23,7 +27,7 @@
<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' )}}
{{ $t('admin.codes.detail.descriptionHint') }}
</div>
<!--
@ -38,7 +42,7 @@
<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' )}}
{{ $t('admin.codes.detail.messageHint') }}
</div>
<div class="pure-controls">
@ -66,33 +70,62 @@ export default {
props: ['codeParam'],
created()
{
var self = this;
if (self.codeParam)
watch: {
'$route' (to, from)
{
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
};
var self = this;
self.checkCode();
}
},
created()
{
var self = this;
self.checkCode();
},
methods: {
checkCode()
{
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
};
}
},
getCodeUrl(code)
{
var port = ':' + window.location.port;
if ((window.location.protocol == 'http:' && window.location.port == 80) ||
(window.location.protocol == 'https:' && window.location.port == 443))
port = '';
return window.location.protocol + '//' +
window.location.hostname + port + '/c/' +
encodeURIComponent(code);
},
save()
{
var self = this;
@ -111,7 +144,10 @@ export default {
})
.then((response) =>
{
this.$router.push('/admin/codes');
if (self.code.id)
this.$router.push('/admin/codes');
else
this.$router.push('/admin/codes/edit/' + response.data);
})
.catch((error) => { shared.$emit('apiError', error, this.$router) })
.then(() =>
@ -121,7 +157,4 @@ export default {
}
}
}
</script>
<style lang="scss">
</style>
</script>

View File

@ -55,12 +55,4 @@ export default {
.catch((error) => { shared.$emit('apiError', error, this.$router) });
}
}
</script>
<style lang="scss">
.description
{
font-size: 75%;
color: #808080;
}
</style>
</script>

View File

@ -187,4 +187,9 @@ $list-padding: .2rem;
}
}
.description
{
font-size: 75%;
color: #808080;
}
</style>

View File

@ -49,7 +49,7 @@ export default {
.then((response) =>
{
shared.adminToken = response.data;
self.$router.push({ path: '/admin/uploads' });
self.$router.push({ name: 'adminDefault' });
})
.catch((error) =>
{

View File

@ -8,7 +8,7 @@
<menu-link route="/admin/users" :title="$t('admin.menu.users')" v-if="hasAuth('manageUsers')"></menu-link>
<li class="pure-menu-item right">
<a class="pure-menu-link" href="#" @click="logout">{{ $t('admin.logout') }}</a>
<a class="pure-menu-link" href="#" @click.prevent="logout">{{ $t('admin.logout') }}</a>
</li>
</ul>
</div>

View File

@ -1,21 +1,23 @@
<template>
<div id="uploads">
<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 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-1"><span class="text codedescription">{{ upload.codedescription || upload.code }}</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 class="pure-u-1-3 right"><span class="text">{{ upload.created | formatDateTime }}</span></div>
</div>
</div>
<div class="pure-menu pure-menu-horizontal">
<ul class="pure-menu-list">
<li class="pure-menu-item" v-if="confirmDelete == upload.id"><a href="#" class="pure-menu-link" @click.prevent="cancelDelete"><fa icon="ban"></fa> {{ $t('admin.cancel') }}</a></li>
<li class="pure-menu-item"><a href="#" class="pure-menu-link" :class="{ confirm: confirmDelete == upload.id }" @click.prevent="deleteClick(upload.id)"><fa icon="trash-alt"></fa> {{ $t('admin.delete') }}</a></li>
</ul>
</div>
<div class="file" v-for="file in upload.files" :title="file.name">
<a :href="getDownloadUrl(file)">
<img :src="getFileIconUrl(file.name)" class="icon">
@ -45,7 +47,8 @@ export default {
data()
{
return {
uploads: null
uploads: null,
confirmDelete: null
};
},
@ -93,6 +96,39 @@ export default {
getDownloadUrl(file)
{
return '/admin/download/' + encodeURIComponent(file.id) + '/' + encodeURIComponent(file.name);
},
deleteClick(uploadId)
{
var self = this;
if (self.confirmDelete == uploadId)
{
self.confirmDelete = null;
axios.delete('/admin/uploads/' + encodeURIComponent(uploadId), {
headers: {
Authorization: 'Bearer ' + shared.adminToken
}})
.then((response) =>
{
var index = _.findIndex(self.uploads, (item) => { return item.id == uploadId; });
if (index > -1)
self.uploads.splice(index, 1);
})
.catch((error) => { shared.$emit('apiError', error, this.$router) });
}
else
{
self.confirmDelete = uploadId;
}
},
cancelDelete()
{
var self = this;
self.confirmDelete = null;
}
}
}
@ -154,4 +190,22 @@ export default {
background-color: #f4f4f4;
padding: .2rem;
}
.codedescription
{
font-weight: bold;
}
.right
{
text-align: right;
}
.confirm
{
color: red;
}
</style>