Add /admin page
This commit is contained in:
@@ -23,6 +23,7 @@ It's an alternative to paid services like Dropbox, WeTransfer.
|
|||||||
* Modal-style file preview
|
* Modal-style file preview
|
||||||
* Requires Node >=7.4 or use `--harmony-async-await` flag
|
* Requires Node >=7.4 or use `--harmony-async-await` flag
|
||||||
* Password protected download list ([AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))
|
* Password protected download list ([AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))
|
||||||
|
* `/admin` Page lists bucket information
|
||||||
|
|
||||||
**See the blog posts about PsiTransfer: https://psi.cx/tags/PsiTransfer/ and checkout the
|
**See the blog posts about PsiTransfer: https://psi.cx/tags/PsiTransfer/ and checkout the
|
||||||
[Documentation](https://github.com/psi-4ward/psitransfer/tree/master/docs)**
|
[Documentation](https://github.com/psi-4ward/psitransfer/tree/master/docs)**
|
||||||
|
|||||||
171
app/src/Admin.vue
Normal file
171
app/src/Admin.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
.download-app
|
||||||
|
.alert.alert-danger(v-show="error")
|
||||||
|
strong
|
||||||
|
i.fa.fa-exclamation-triangle
|
||||||
|
| {{ error }}
|
||||||
|
form.well(v-if='!loggedIn', @submit.stop.prevent="login")
|
||||||
|
h3 Password
|
||||||
|
.form-group
|
||||||
|
input.form-control(type='password', v-model='password', autofocus="")
|
||||||
|
p.text-danger(v-show='passwordWrong')
|
||||||
|
strong Access denied!
|
||||||
|
|
|
||||||
|
button.btn.btn-primary(type="submit")
|
||||||
|
i.fa.fa-sign-in
|
||||||
|
| login
|
||||||
|
|
||||||
|
div(v-if="loggedIn")
|
||||||
|
table.table.table-hover
|
||||||
|
thead
|
||||||
|
tr
|
||||||
|
th SID
|
||||||
|
th Created
|
||||||
|
th Downloaded
|
||||||
|
th Expire
|
||||||
|
th Size
|
||||||
|
template(v-for="(bucket, sid) in db")
|
||||||
|
tbody(:class="{expanded: expand===sid}")
|
||||||
|
tr.bucket(@click="expandView(sid)")
|
||||||
|
td
|
||||||
|
| {{ sid }}
|
||||||
|
i.fa.fa-key.pull-right(v-if="sum[sid].password", title="Password protected")
|
||||||
|
td {{ sum[sid].created | date }}
|
||||||
|
td
|
||||||
|
template(v-if="sum[sid].lastDownload") {{ sum[sid].lastDownload | date}}
|
||||||
|
template(v-else="") -
|
||||||
|
td
|
||||||
|
template(v-if="typeof sum[sid].firstExpire === 'number'") {{ sum[sid].firstExpire | date }}
|
||||||
|
template(v-else) {{ sum[sid].firstExpire }}
|
||||||
|
td.text-right {{ humanFileSize(sum[sid].size) }}
|
||||||
|
tbody.expanded(v-if="expand === sid")
|
||||||
|
template(v-for="file in bucket")
|
||||||
|
tr.file
|
||||||
|
td {{ file.metadata.name }}
|
||||||
|
td {{+file.metadata.createdAt | date}}
|
||||||
|
td
|
||||||
|
template(v-if="file.metadata.lastDownload") {{ +file.metadata.lastDownload | date}}
|
||||||
|
template(v-else="") -
|
||||||
|
td
|
||||||
|
template(v-if="typeof file.expireDate === 'number'") {{ file.expireDate | date }}
|
||||||
|
template(v-else) {{ file.expireDate }}
|
||||||
|
td.text-right {{ humanFileSize(file.size) }}
|
||||||
|
tfoot
|
||||||
|
tr
|
||||||
|
td(colspan="3")
|
||||||
|
td.text-right(colspan="2") Sum: {{ humanFileSize(sizeSum) }}
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'app',
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
db: {},
|
||||||
|
sum: {},
|
||||||
|
loggedIn: false,
|
||||||
|
password: '',
|
||||||
|
error: '',
|
||||||
|
passwordWrong: false,
|
||||||
|
expand: false,
|
||||||
|
sizeSum: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
expandView(sid) {
|
||||||
|
if(this.expand === sid) return this.expand = false;
|
||||||
|
this.expand = sid;
|
||||||
|
},
|
||||||
|
|
||||||
|
login() {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/admin/data.json');
|
||||||
|
xhr.setRequestHeader("x-passwd", this.password);
|
||||||
|
xhr.onload = () => {
|
||||||
|
if(xhr.status === 200) {
|
||||||
|
try {
|
||||||
|
this.db = JSON.parse(xhr.responseText);
|
||||||
|
this.expandDb();
|
||||||
|
this.loggedIn = true;
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
this.error = e.toString();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(xhr.status === 403) this.passwordWrong = true;
|
||||||
|
else this.error = `${xhr.status} ${xhr.statusText}: ${xhr.responseText}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
},
|
||||||
|
|
||||||
|
expandDb() {
|
||||||
|
Object.keys(this.db).forEach(sid => {
|
||||||
|
const sum = {
|
||||||
|
firstExpire: Number.MAX_SAFE_INTEGER,
|
||||||
|
lastDownload: 0,
|
||||||
|
created: Number.MAX_SAFE_INTEGER,
|
||||||
|
password: false,
|
||||||
|
size: 0
|
||||||
|
};
|
||||||
|
this.db[sid].forEach(file => {
|
||||||
|
sum.size += file.size;
|
||||||
|
if(file.metadata._password) {
|
||||||
|
sum.password = true;
|
||||||
|
}
|
||||||
|
if(+file.metadata.createdAt < sum.created) {
|
||||||
|
sum.created = +file.metadata.createdAt;
|
||||||
|
}
|
||||||
|
if(file.metadata.lastDownload && +file.metadata.lastDownload > sum.lastDownload) {
|
||||||
|
sum.lastDownload = +file.metadata.lastDownload;
|
||||||
|
}
|
||||||
|
if(file.metadata.retention === 'one-time') {
|
||||||
|
sum.firstExpire = 'one-time';
|
||||||
|
file.expireDate = file.metadata.retention;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
file.expireDate = +file.metadata.createdAt + (+file.metadata.retention * 1000);
|
||||||
|
if(sum.firstExpire > file.expireDate) sum.firstExpire = file.expireDate;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.sizeSum += sum.size;
|
||||||
|
this.$set(this.sum, sid, sum);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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.00).toFixed(2) + byteUnits[i];
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bucket {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.expanded {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.expanded .bucket td {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
tfoot {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
34
app/src/admin.js
Normal file
34
app/src/admin.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import 'babel-polyfill';
|
||||||
|
import Vue from 'vue';
|
||||||
|
import Admin from './Admin.vue';
|
||||||
|
|
||||||
|
function parseDate(str) {
|
||||||
|
if(!str) return str;
|
||||||
|
return new Date(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dt) {
|
||||||
|
if(dt === null) return "";
|
||||||
|
const f = function(d) {
|
||||||
|
return d < 10 ? '0' + d : d;
|
||||||
|
};
|
||||||
|
return dt.getFullYear() + '-' + f(dt.getMonth() + 1) + '-' + f(dt.getDate()) + ' ' + f(dt.getHours()) + ':' + f(dt.getMinutes());
|
||||||
|
}
|
||||||
|
function isDate(d) {
|
||||||
|
return Object.prototype.toString.call(d) === '[object Date]';
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.filter('date', function(val, format) {
|
||||||
|
if(!isDate(val)) {
|
||||||
|
val = parseDate(val);
|
||||||
|
}
|
||||||
|
return isDate(val) ? formatDate(val, format) : val;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#admin',
|
||||||
|
render: h => h(Admin)
|
||||||
|
});
|
||||||
|
|
||||||
|
window.PSITRANSFER_VERSION = PSITRANSFER_VERSION;
|
||||||
@@ -12,6 +12,7 @@ module.exports = {
|
|||||||
entry: {
|
entry: {
|
||||||
upload: './src/upload.js',
|
upload: './src/upload.js',
|
||||||
download: './src/download.js',
|
download: './src/download.js',
|
||||||
|
admin: './src/admin.js',
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, '../public/app'),
|
path: path.resolve(__dirname, '../public/app'),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ module.exports = {
|
|||||||
"1209600": "2 Weeks"
|
"1209600": "2 Weeks"
|
||||||
},
|
},
|
||||||
"defaultRetention": 3600,
|
"defaultRetention": 3600,
|
||||||
"sslKeyFile": './tmp/cert.key',
|
"adminPass": "admin"
|
||||||
"sslCertFile": './tmp/cert.pem',
|
// "sslKeyFile": './tmp/cert.key',
|
||||||
|
// "sslCertFile": './tmp/cert.pem',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const config = {
|
|||||||
"2419200": "4 Weeks",
|
"2419200": "4 Weeks",
|
||||||
"4838400": "8 Weeks"
|
"4838400": "8 Weeks"
|
||||||
},
|
},
|
||||||
|
// admin password, set to false to disable /admin page
|
||||||
|
"adminPass": false,
|
||||||
"defaultRetention": 604800,
|
"defaultRetention": 604800,
|
||||||
// expire every file after maxAge (eg never downloaded one-time files)
|
// expire every file after maxAge (eg never downloaded one-time files)
|
||||||
"maxAge": 3600*24*75, // 75 days
|
"maxAge": 3600*24*75, // 75 days
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ module.exports = class DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async updateLastDownload(sid, key) {
|
||||||
|
debug(`Update last download ${sid}++${key}`);
|
||||||
|
const data = this.get(sid).find(item => item.key === key);
|
||||||
|
if(!data) return;
|
||||||
|
data.metadata.lastDownload = Date.now();
|
||||||
|
await this.store.update(`${sid}++${key}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
get(sid) {
|
get(sid) {
|
||||||
return this.db[sid];
|
return this.db[sid];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const MD5 = require("crypto-js/md5");
|
|||||||
const debug = require('debug')('psitransfer:main');
|
const debug = require('debug')('psitransfer:main');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString();
|
const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString();
|
||||||
const store = new Store(config.uploadDir);
|
const store = new Store(config.uploadDir);
|
||||||
@@ -54,6 +55,32 @@ app.get('/config.json', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.get('/admin', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../public/html/admin.html'));
|
||||||
|
});
|
||||||
|
app.get('/admin/data.json', (req, res) => {
|
||||||
|
if(!config.adminPass || !req.get('x-passwd')) return res.status(401).send('Unauthorized');
|
||||||
|
if(req.get('x-passwd') !== config.adminPass) return res.status(403).send('Forbidden');
|
||||||
|
|
||||||
|
const result = _.chain(db.db)
|
||||||
|
.cloneDeep()
|
||||||
|
.forEach(bucket => {
|
||||||
|
bucket.forEach(file => {
|
||||||
|
if(file.metadata.password) {
|
||||||
|
file.metadata._password = true;
|
||||||
|
delete file.metadata.password;
|
||||||
|
delete file.metadata.key;
|
||||||
|
delete file.key;
|
||||||
|
delete file.url;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.value();
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// List files / Download App
|
// List files / Download App
|
||||||
app.get('/:sid', (req, res, next) => {
|
app.get('/:sid', (req, res, next) => {
|
||||||
if(req.url.endsWith('.json')) {
|
if(req.url.endsWith('.json')) {
|
||||||
@@ -128,6 +155,8 @@ app.get('/files/:fid', async(req, res, next) => {
|
|||||||
bucket.forEach(async info => {
|
bucket.forEach(async info => {
|
||||||
if(info.metadata.retention === 'one-time') {
|
if(info.metadata.retention === 'one-time') {
|
||||||
await db.remove(info.metadata.sid, info.metadata.key);
|
await db.remove(info.metadata.sid, info.metadata.key);
|
||||||
|
} else {
|
||||||
|
await db.updateLastDownload(info.metadata.sid, info.metadata.key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -145,11 +174,13 @@ app.get('/files/:fid', async(req, res, next) => {
|
|||||||
res.download(store.getFilename(req.params.fid), info.metadata.name);
|
res.download(store.getFilename(req.params.fid), info.metadata.name);
|
||||||
|
|
||||||
// remove one-time files after download
|
// remove one-time files after download
|
||||||
if(info.metadata.retention === 'one-time') {
|
|
||||||
res.on('finish', async () => {
|
res.on('finish', async () => {
|
||||||
|
if(info.metadata.retention === 'one-time') {
|
||||||
await db.remove(info.metadata.sid, info.metadata.key);
|
await db.remove(info.metadata.sid, info.metadata.key);
|
||||||
});
|
} else {
|
||||||
|
await db.updateLastDownload(info.metadata.sid, info.metadata.key);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
res.status(404).send(errorPage.replace('%%ERROR%%', e.message));
|
res.status(404).send(errorPage.replace('%%ERROR%%', e.message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async update(fid, data) {
|
||||||
|
debug(`Update File ${this.getFilename(fid)}`);
|
||||||
|
await fsp.writeJson(this.getFilename(fid) + '.json', data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async info(fid) {
|
async info(fid) {
|
||||||
try {
|
try {
|
||||||
const info = await fsp.readJson(this.getFilename(fid) + '.json');
|
const info = await fsp.readJson(this.getFilename(fid) + '.json');
|
||||||
|
|||||||
36
public/html/admin.html
Normal file
36
public/html/admin.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>PsiTransfer Admin</title>
|
||||||
|
<link href="/assets/favicon.ico" rel="icon" type="image/x-icon">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<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">
|
||||||
|
<link href="/assets/styles.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>
|
||||||
|
<i class="fa fa-fw fa-gears" style="color: #0275D8"></i>
|
||||||
|
PsiTransfer Admin
|
||||||
|
</h1>
|
||||||
|
<hr>
|
||||||
|
<div id="admin"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container text-right">
|
||||||
|
<span class="text-muted"><a href="https://github.com/psi-4ward/psitransfer" target="_blank">Powered by PsiTransfer</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/app/common.js"></script>
|
||||||
|
<script src="/app/admin.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user