From c7b354386de286588c02d1e24c969b72eb96b984 Mon Sep 17 00:00:00 2001 From: Christoph Wiechert Date: Tue, 25 Apr 2017 18:22:24 +0200 Subject: [PATCH 1/5] Add download bucket as zip archive --- app/src/Download.vue | 35 ++++++++++++++++++++++++++++----- lib/endpoints.js | 46 ++++++++++++++++++++++++++++++++++++++++++++ lib/store.js | 2 +- package.json | 12 +++++++++--- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/app/src/Download.vue b/app/src/Download.vue index 860f91c..2670073 100644 --- a/app/src/Download.vue +++ b/app/src/Download.vue @@ -19,7 +19,11 @@ i.fa.fa-key | decrypt .panel.panel-primary(v-if='!needsPassword') - .panel-heading Files + .panel-heading + a.pull-right(style="color:#fff", @click="downloadAll", v-if="downloadsAvailable") + i.fa.fa-fw.fa-download + | Download ZIP + | Files .panel-body table.table.table-hover.table-striped(style='margin-bottom: 0') tbody @@ -32,10 +36,8 @@ a i.fa.fa-fw.fa-copy i.fa.fa-check.text-success.pull-right(v-show='file.downloaded') - | - strong {{ file.metadata.name }} - | - small ({{ humanFileSize(file.size) }}) + strong {{ file.metadata.name }} + small(v-if="Number.isFinite(file.size)") ({{ humanFileSize(file.size) }}) p {{ file.metadata.comment }} @@ -44,6 +46,7 @@ "use strict"; import AES from 'crypto-js/aes'; import encUtf8 from 'crypto-js/enc-utf8'; + import MD5 from 'crypto-js/md5'; import FileIcon from './common/FileIcon.vue'; import Clipboard from './common/Clipboard.vue'; @@ -51,6 +54,7 @@ export default { name: 'app', components: { FileIcon, Clipboard }, + data () { return { files: [], @@ -63,6 +67,13 @@ host: document.location.protocol + '//' + document.location.host } }, + + computed: { + downloadsAvailable: function() { + return this.files.some(f => !f.downloaded || f.metadata.retention !== 'one-time') + } + }, + methods: { download(file) { if(file.downloaded && file.metadata.retention === 'one-time') { @@ -73,6 +84,20 @@ file.downloaded = true; }, + downloadAll() { + document.location.href = document.location.protocol + '//' + document.location.host + + '/files/' + this.sid + '++' + + MD5( + this.files + .filter(f => !f.downloaded || f.metadata.retention !== 'one-time') + .map(f => f.key).join() + ).toString() + '.zip'; + + this.files.forEach(f => { + f.downloaded = true; + }); + }, + copied(file, $event) { file.downloaded = $event === 'copied'; }, diff --git a/lib/endpoints.js b/lib/endpoints.js index 734f17a..a1f6e83 100644 --- a/lib/endpoints.js +++ b/lib/endpoints.js @@ -11,7 +11,9 @@ const fs = require("fs"); const tusMeta = require('tus-metadata'); const assert = require('assert'); const AES = require("crypto-js/aes"); +const MD5 = require("crypto-js/md5"); const debug = require('debug')('psitransfer:main'); +const archiver = require('archiver'); const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString(); const store = new Store(config.uploadDir); @@ -73,6 +75,50 @@ app.get('/files/:fid', async(req, res, next) => { // let tusboy handle HEAD with Tus Header if(req.method === 'HEAD' && req.get('Tus-Resumable')) return next(); + // Download all files + if(req.params.fid.endsWith('.zip')) { + const sid = req.params.fid.split('++')[0]; + const bucket = db.get(sid); + if(req.params.fid !== sid + '++' + MD5(bucket.map(f => f.key).join()).toString() + '.zip') { + res.status(404).send(errorPage.replace('%%ERROR%%', 'Invalid link')); + return; + } + debug(`Download Bucket ${sid}`); + + res.header('ContentType', 'application/zip'); + res.header('Content-Disposition', 'attachment; filename="' + sid + '.zip"'); + + const archive = archiver('zip'); + archive.on('error', function(err) { + console.error(err); + }); + + bucket.forEach(info => { + archive.append( + fs.createReadStream(store.getFilename(info.metadata.sid + '++' + info.key)), + {name: info.metadata.name} + ); + }); + + archive.pipe(res); + archive.finalize(); + + try { + res.on('finish', async () => { + bucket.forEach(async info => { + if(info.metadata.retention === 'one-time') { + await db.remove(info.metadata.sid, info.metadata.key); + } + }); + }); + } catch(e) { + console.error(e); + } + + return; + } + + // Download single file debug(`Download ${req.params.fid}`); try { const info = await store.info(req.params.fid); // throws on 404 diff --git a/lib/store.js b/lib/store.js index bc51c4d..9b13340 100644 --- a/lib/store.js +++ b/lib/store.js @@ -97,7 +97,7 @@ class Store { if(!end) end = info.size - 1; contentLength = end - start + 1 } - cb({ contentLength, metadata: info.metadata, info }); + if(cb) cb({ contentLength, metadata: info.metadata, info }); }); return fsp.createReadStream(this.getFilename(fid), {start, end}); } diff --git a/package.json b/package.json index 5b74e5b..8f396c5 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,18 @@ "name": "psitransfer", "version": "1.0.0", "description": "Open self-hoste file-sharing solution", - "keywords": ["share","upload","transfer","files","wetransfer"], + "keywords": [ + "share", + "upload", + "transfer", + "files", + "wetransfer" + ], "repository": "psi-4ward/psitransfer", "bugs": "https://github.com/psi-4ward/psitransfer/issues", "main": "app.js", "dependencies": { + "archiver": "^1.3.0", "compression": "^1.6.2", "crypto-js": "^3.1.9-1", "debug": "^2.6.0", @@ -18,8 +25,7 @@ "tusboy": "^1.1.1", "uuid": "^3.0.1" }, - "devDependencies": { - }, + "devDependencies": {}, "scripts": { "start": "NODE_ENV=production node app.js", "dev": "NODE_ENV=dev DEBUG=psitransfer:* nodemon -i app -i dist -i data app.js", From 377d363770abcb5f97495418fc8d1bf2178a37af Mon Sep 17 00:00:00 2001 From: Christoph Wiechert Date: Tue, 25 Apr 2017 21:52:20 +0200 Subject: [PATCH 2/5] Add download-all as tar.gz and style improvements --- app/src/Download.vue | 25 +++++++++++++++---------- lib/endpoints.js | 24 ++++++++++++++++-------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/app/src/Download.vue b/app/src/Download.vue index 2670073..01777b7 100644 --- a/app/src/Download.vue +++ b/app/src/Download.vue @@ -20,10 +20,15 @@ | decrypt .panel.panel-primary(v-if='!needsPassword') .panel-heading - a.pull-right(style="color:#fff", @click="downloadAll", v-if="downloadsAvailable") - i.fa.fa-fw.fa-download - | Download ZIP - | Files + strong Files + div.pull-right(style="margin-top:-5px;") + span.btn-group + a.btn.btn-sm.btn-default(@click="downloadAll('zip')", title="Archive download is not resumeable!") + i.fa.fa-fw.fa-fw.fa-download + | zip + a.btn.btn-sm.btn-default(@click="downloadAll('tar.gz')", title="Archive download is not resumeable!") + i.fa.fa-fw.fa-fw.fa-download + | tar.gz .panel-body table.table.table-hover.table-striped(style='margin-bottom: 0') tbody @@ -70,7 +75,7 @@ computed: { downloadsAvailable: function() { - return this.files.some(f => !f.downloaded || f.metadata.retention !== 'one-time') + return this.files.length > 1 && this.files.some(f => !f.downloaded || f.metadata.retention !== 'one-time') } }, @@ -84,14 +89,14 @@ file.downloaded = true; }, - downloadAll() { + downloadAll(format) { document.location.href = document.location.protocol + '//' + document.location.host + '/files/' + this.sid + '++' + MD5( - this.files - .filter(f => !f.downloaded || f.metadata.retention !== 'one-time') - .map(f => f.key).join() - ).toString() + '.zip'; + this.files + .filter(f => !f.downloaded || f.metadata.retention !== 'one-time') + .map(f => f.key).join() + ).toString() + '.' + format; this.files.forEach(f => { f.downloaded = true; diff --git a/lib/endpoints.js b/lib/endpoints.js index a1f6e83..66315f9 100644 --- a/lib/endpoints.js +++ b/lib/endpoints.js @@ -14,6 +14,7 @@ const AES = require("crypto-js/aes"); const MD5 = require("crypto-js/md5"); const debug = require('debug')('psitransfer:main'); const archiver = require('archiver'); +const zlib = require('zlib'); const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString(); const store = new Store(config.uploadDir); @@ -70,25 +71,28 @@ app.get('/:sid', (req, res, next) => { }); -// Download single file +// Download files app.get('/files/:fid', async(req, res, next) => { - // let tusboy handle HEAD with Tus Header + // let tusboy handle HEAD requests with Tus Header if(req.method === 'HEAD' && req.get('Tus-Resumable')) return next(); // Download all files - if(req.params.fid.endsWith('.zip')) { + if(req.params.fid.match(/^[a-z0-9+]+\.(tar\.gz|zip)$/)) { const sid = req.params.fid.split('++')[0]; + const format = req.params.fid.endsWith('.zip') ? 'zip' : 'tar.gz'; const bucket = db.get(sid); - if(req.params.fid !== sid + '++' + MD5(bucket.map(f => f.key).join()).toString() + '.zip') { + + if(req.params.fid !== sid + '++' + MD5(bucket.map(f => f.key).join()).toString() + '.' + format) { res.status(404).send(errorPage.replace('%%ERROR%%', 'Invalid link')); return; } debug(`Download Bucket ${sid}`); - res.header('ContentType', 'application/zip'); - res.header('Content-Disposition', 'attachment; filename="' + sid + '.zip"'); + if(format === 'zip') res.header('ContentType', 'application/zip'); + if(format === 'tar.gz') res.header('ContentType', 'application/x-gtar'); + res.header('Content-Disposition', `attachment; filename="${sid}.${format}"`); - const archive = archiver('zip'); + const archive = archiver(format === 'zip' ? 'zip' : 'tar'); archive.on('error', function(err) { console.error(err); }); @@ -100,7 +104,11 @@ app.get('/files/:fid', async(req, res, next) => { ); }); - archive.pipe(res); + if(format === 'tar.gz') { + archive.pipe(zlib.createGzip()).pipe(res); + } else { + archive.pipe(res); + } archive.finalize(); try { From f7fd92f141b4afbd54d55610ba0cb2c6d3fcbccc Mon Sep 17 00:00:00 2001 From: Christoph Wiechert Date: Tue, 25 Apr 2017 21:55:35 +0200 Subject: [PATCH 3/5] Added Readme note for download-zip --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 963cbe1..0949aeb 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,13 @@ Psitransfer uses [debug](https://github.com/visionmedia/debug): DEBUG=psitransfer:* npm start ``` +## Side notes + +* There is no (end-to-end) payload encryption (yet). +* `Download all as ZIP` does not support resuming the download. + +:star2: Contribution is highly welcome :metal: + ## License [BSD](LICENSE) From 7ee6e80fda12adfd523024bfcb05d6f12d7b02a7 Mon Sep 17 00:00:00 2001 From: Christoph Wiechert Date: Sun, 7 May 2017 23:15:31 +0200 Subject: [PATCH 4/5] Fix: handle zip-download for not existing bundle --- lib/endpoints.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/endpoints.js b/lib/endpoints.js index b39214c..184c7f3 100644 --- a/lib/endpoints.js +++ b/lib/endpoints.js @@ -86,6 +86,8 @@ app.get('/files/:fid', async(req, res, next) => { const format = req.params.fid.endsWith('.zip') ? 'zip' : 'tar.gz'; const bucket = db.get(sid); + if(!bucket) return res.status(404).send(errorPage.replace('%%ERROR%%', 'Download bucket not found.')); + if(req.params.fid !== sid + '++' + MD5(bucket.map(f => f.key).join()).toString() + '.' + format) { res.status(404).send(errorPage.replace('%%ERROR%%', 'Invalid link')); return; From e53506363a4b1c7544bc74eb1b10efdbcc09bcb1 Mon Sep 17 00:00:00 2001 From: Christoph Wiechert Date: Sun, 7 May 2017 23:16:17 +0200 Subject: [PATCH 5/5] Fix: Correctly hide zip/tar.gz download btns --- app/src/Download.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/Download.vue b/app/src/Download.vue index 01777b7..e3bc7a8 100644 --- a/app/src/Download.vue +++ b/app/src/Download.vue @@ -22,7 +22,7 @@ .panel-heading strong Files div.pull-right(style="margin-top:-5px;") - span.btn-group + span.btn-group(v-if="downloadsAvailable") a.btn.btn-sm.btn-default(@click="downloadAll('zip')", title="Archive download is not resumeable!") i.fa.fa-fw.fa-fw.fa-download | zip @@ -75,7 +75,7 @@ computed: { downloadsAvailable: function() { - return this.files.length > 1 && this.files.some(f => !f.downloaded || f.metadata.retention !== 'one-time') + return this.files.filter(f => !f.downloaded || f.metadata.retention !== 'one-time').length > 0 } },