Skip to content

Commit 1bb9d57

Browse files
committed
Add /admin page
1 parent 4c2f5c1 commit 1bb9d57

10 files changed

+300
-7
lines changed

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ It's an alternative to paid services like Dropbox, WeTransfer.
2222
* Download all files as zip/tar.gz archive
2323
* Modal-style file preview
2424
* Requires Node >=7.4 or use `--harmony-async-await` flag
25-
* Password protected download list ([AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))
25+
* Password protected download list ([AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))
26+
* `/admin` Page lists bucket information
2627

2728
**See the blog posts about PsiTransfer: https://psi.cx/tags/PsiTransfer/ and checkout the
2829
[Documentation](https://github.com/psi-4ward/psitransfer/tree/master/docs)**

app/src/Admin.vue

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<template lang="pug">
2+
.download-app
3+
.alert.alert-danger(v-show="error")
4+
strong
5+
i.fa.fa-exclamation-triangle
6+
| {{ error }}
7+
form.well(v-if='!loggedIn', @submit.stop.prevent="login")
8+
h3 Password
9+
.form-group
10+
input.form-control(type='password', v-model='password', autofocus="")
11+
p.text-danger(v-show='passwordWrong')
12+
strong Access denied!
13+
|
14+
button.btn.btn-primary(type="submit")
15+
i.fa.fa-sign-in
16+
| login
17+
18+
div(v-if="loggedIn")
19+
table.table.table-hover
20+
thead
21+
tr
22+
th SID
23+
th Created
24+
th Downloaded
25+
th Expire
26+
th Size
27+
template(v-for="(bucket, sid) in db")
28+
tbody(:class="{expanded: expand===sid}")
29+
tr.bucket(@click="expandView(sid)")
30+
td
31+
| {{ sid }}
32+
i.fa.fa-key.pull-right(v-if="sum[sid].password", title="Password protected")
33+
td {{ sum[sid].created | date }}
34+
td
35+
template(v-if="sum[sid].lastDownload") {{ sum[sid].lastDownload | date}}
36+
template(v-else="") -
37+
td
38+
template(v-if="typeof sum[sid].firstExpire === 'number'") {{ sum[sid].firstExpire | date }}
39+
template(v-else) {{ sum[sid].firstExpire }}
40+
td.text-right {{ humanFileSize(sum[sid].size) }}
41+
tbody.expanded(v-if="expand === sid")
42+
template(v-for="file in bucket")
43+
tr.file
44+
td {{ file.metadata.name }}
45+
td {{+file.metadata.createdAt | date}}
46+
td
47+
template(v-if="file.metadata.lastDownload") {{ +file.metadata.lastDownload | date}}
48+
template(v-else="") -
49+
td
50+
template(v-if="typeof file.expireDate === 'number'") {{ file.expireDate | date }}
51+
template(v-else) {{ file.expireDate }}
52+
td.text-right {{ humanFileSize(file.size) }}
53+
tfoot
54+
tr
55+
td(colspan="3")
56+
td.text-right(colspan="2") Sum: {{ humanFileSize(sizeSum) }}
57+
58+
</template>
59+
60+
61+
<script>
62+
"use strict";
63+
64+
export default {
65+
name: 'app',
66+
67+
data () {
68+
return {
69+
db: {},
70+
sum: {},
71+
loggedIn: false,
72+
password: '',
73+
error: '',
74+
passwordWrong: false,
75+
expand: false,
76+
sizeSum: 0
77+
}
78+
},
79+
80+
methods: {
81+
expandView(sid) {
82+
if(this.expand === sid) return this.expand = false;
83+
this.expand = sid;
84+
},
85+
86+
login() {
87+
const xhr = new XMLHttpRequest();
88+
xhr.open('GET', '/admin/data.json');
89+
xhr.setRequestHeader("x-passwd", this.password);
90+
xhr.onload = () => {
91+
if(xhr.status === 200) {
92+
try {
93+
this.db = JSON.parse(xhr.responseText);
94+
this.expandDb();
95+
this.loggedIn = true;
96+
}
97+
catch(e) {
98+
this.error = e.toString();
99+
}
100+
} else {
101+
if(xhr.status === 403) this.passwordWrong = true;
102+
else this.error = `${xhr.status} ${xhr.statusText}: ${xhr.responseText}`;
103+
}
104+
};
105+
xhr.send();
106+
},
107+
108+
expandDb() {
109+
Object.keys(this.db).forEach(sid => {
110+
const sum = {
111+
firstExpire: Number.MAX_SAFE_INTEGER,
112+
lastDownload: 0,
113+
created: Number.MAX_SAFE_INTEGER,
114+
password: false,
115+
size: 0
116+
};
117+
this.db[sid].forEach(file => {
118+
sum.size += file.size;
119+
if(file.metadata._password) {
120+
sum.password = true;
121+
}
122+
if(+file.metadata.createdAt < sum.created) {
123+
sum.created = +file.metadata.createdAt;
124+
}
125+
if(file.metadata.lastDownload && +file.metadata.lastDownload > sum.lastDownload) {
126+
sum.lastDownload = +file.metadata.lastDownload;
127+
}
128+
if(file.metadata.retention === 'one-time') {
129+
sum.firstExpire = 'one-time';
130+
file.expireDate = file.metadata.retention;
131+
}
132+
else {
133+
file.expireDate = +file.metadata.createdAt + (+file.metadata.retention * 1000);
134+
if(sum.firstExpire > file.expireDate) sum.firstExpire = file.expireDate;
135+
}
136+
});
137+
this.sizeSum += sum.size;
138+
this.$set(this.sum, sid, sum);
139+
});
140+
},
141+
142+
humanFileSize(fileSizeInBytes) {
143+
let i = -1;
144+
const byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB'];
145+
do {
146+
fileSizeInBytes = fileSizeInBytes / 1024;
147+
i++;
148+
} while(fileSizeInBytes > 1024);
149+
return Math.max(fileSizeInBytes, 0.00).toFixed(2) + byteUnits[i];
150+
},
151+
152+
},
153+
154+
155+
}
156+
</script>
157+
158+
<style>
159+
.bucket {
160+
cursor: pointer;
161+
}
162+
.expanded {
163+
background: #fafafa;
164+
}
165+
.expanded .bucket td {
166+
font-weight: bold;
167+
}
168+
tfoot {
169+
font-weight: bold;
170+
}
171+
</style>

app/src/admin.js

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import 'babel-polyfill';
2+
import Vue from 'vue';
3+
import Admin from './Admin.vue';
4+
5+
function parseDate(str) {
6+
if(!str) return str;
7+
return new Date(str);
8+
}
9+
10+
function formatDate(dt) {
11+
if(dt === null) return "";
12+
const f = function(d) {
13+
return d < 10 ? '0' + d : d;
14+
};
15+
return dt.getFullYear() + '-' + f(dt.getMonth() + 1) + '-' + f(dt.getDate()) + ' ' + f(dt.getHours()) + ':' + f(dt.getMinutes());
16+
}
17+
function isDate(d) {
18+
return Object.prototype.toString.call(d) === '[object Date]';
19+
}
20+
21+
Vue.filter('date', function(val, format) {
22+
if(!isDate(val)) {
23+
val = parseDate(val);
24+
}
25+
return isDate(val) ? formatDate(val, format) : val;
26+
});
27+
28+
29+
new Vue({
30+
el: '#admin',
31+
render: h => h(Admin)
32+
});
33+
34+
window.PSITRANSFER_VERSION = PSITRANSFER_VERSION;

app/webpack.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = {
1212
entry: {
1313
upload: './src/upload.js',
1414
download: './src/download.js',
15+
admin: './src/admin.js',
1516
},
1617
output: {
1718
path: path.resolve(__dirname, '../public/app'),

config.dev.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
"1209600": "2 Weeks"
1515
},
1616
"defaultRetention": 3600,
17-
"sslKeyFile": './tmp/cert.key',
18-
"sslCertFile": './tmp/cert.pem',
17+
"adminPass": "admin"
18+
// "sslKeyFile": './tmp/cert.key',
19+
// "sslCertFile": './tmp/cert.pem',
1920
};

config.js

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const config = {
2626
"2419200": "4 Weeks",
2727
"4838400": "8 Weeks"
2828
},
29+
// admin password, set to false to disable /admin page
30+
"adminPass": false,
2931
"defaultRetention": 604800,
3032
// expire every file after maxAge (eg never downloaded one-time files)
3133
"maxAge": 3600*24*75, // 75 days

lib/db.js

+9
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ module.exports = class DB {
116116
}
117117

118118

119+
async updateLastDownload(sid, key) {
120+
debug(`Update last download ${sid}++${key}`);
121+
const data = this.get(sid).find(item => item.key === key);
122+
if(!data) return;
123+
data.metadata.lastDownload = Date.now();
124+
await this.store.update(`${sid}++${key}`, data);
125+
}
126+
127+
119128
get(sid) {
120129
return this.db[sid];
121130
}

lib/endpoints.js

+35-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const MD5 = require("crypto-js/md5");
1515
const debug = require('debug')('psitransfer:main');
1616
const archiver = require('archiver');
1717
const zlib = require('zlib');
18+
const _ = require('lodash');
1819

1920
const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString();
2021
const store = new Store(config.uploadDir);
@@ -54,6 +55,32 @@ app.get('/config.json', (req, res) => {
5455
});
5556

5657

58+
app.get('/admin', (req, res) => {
59+
res.sendFile(path.join(__dirname, '../public/html/admin.html'));
60+
});
61+
app.get('/admin/data.json', (req, res) => {
62+
if(!config.adminPass || !req.get('x-passwd')) return res.status(401).send('Unauthorized');
63+
if(req.get('x-passwd') !== config.adminPass) return res.status(403).send('Forbidden');
64+
65+
const result = _.chain(db.db)
66+
.cloneDeep()
67+
.forEach(bucket => {
68+
bucket.forEach(file => {
69+
if(file.metadata.password) {
70+
file.metadata._password = true;
71+
delete file.metadata.password;
72+
delete file.metadata.key;
73+
delete file.key;
74+
delete file.url;
75+
}
76+
})
77+
})
78+
.value();
79+
80+
res.json(result);
81+
});
82+
83+
5784
// List files / Download App
5885
app.get('/:sid', (req, res, next) => {
5986
if(req.url.endsWith('.json')) {
@@ -128,6 +155,8 @@ app.get('/files/:fid', async(req, res, next) => {
128155
bucket.forEach(async info => {
129156
if(info.metadata.retention === 'one-time') {
130157
await db.remove(info.metadata.sid, info.metadata.key);
158+
} else {
159+
await db.updateLastDownload(info.metadata.sid, info.metadata.key);
131160
}
132161
});
133162
});
@@ -145,11 +174,13 @@ app.get('/files/:fid', async(req, res, next) => {
145174
res.download(store.getFilename(req.params.fid), info.metadata.name);
146175

147176
// remove one-time files after download
148-
if(info.metadata.retention === 'one-time') {
149-
res.on('finish', async () => {
177+
res.on('finish', async () => {
178+
if(info.metadata.retention === 'one-time') {
150179
await db.remove(info.metadata.sid, info.metadata.key);
151-
});
152-
}
180+
} else {
181+
await db.updateLastDownload(info.metadata.sid, info.metadata.key);
182+
}
183+
});
153184
} catch(e) {
154185
res.status(404).send(errorPage.replace('%%ERROR%%', e.message));
155186
}

lib/store.js

+7
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ class Store {
4545
}
4646

4747

48+
async update(fid, data) {
49+
debug(`Update File ${this.getFilename(fid)}`);
50+
await fsp.writeJson(this.getFilename(fid) + '.json', data);
51+
return data;
52+
}
53+
54+
4855
async info(fid) {
4956
try {
5057
const info = await fsp.readJson(this.getFilename(fid) + '.json');

public/html/admin.html

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>PsiTransfer Admin</title>
6+
<link href="/assets/favicon.ico" rel="icon" type="image/x-icon">
7+
<meta name="viewport" content="width=device-width, initial-scale=1">
8+
<meta name="robots" content="noindex,nofollow">
9+
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
10+
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
11+
<link href="/assets/styles.css" rel="stylesheet">
12+
</head>
13+
14+
<body>
15+
16+
<div class="container">
17+
<h1>
18+
<i class="fa fa-fw fa-gears" style="color: #0275D8"></i>
19+
PsiTransfer Admin
20+
</h1>
21+
<hr>
22+
<div id="admin"></div>
23+
</div>
24+
25+
<footer class="footer">
26+
<div class="container text-right">
27+
<span class="text-muted"><a href="https://github.com/psi-4ward/psitransfer" target="_blank">Powered by PsiTransfer</a></span>
28+
</div>
29+
</footer>
30+
31+
<script src="/app/common.js"></script>
32+
<script src="/app/admin.js"></script>
33+
34+
</body>
35+
36+
</html>

0 commit comments

Comments
 (0)