diff --git a/README.md b/README.md index 2f487f7..adbfa06 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ 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: diff --git a/app/src/Download.vue b/app/src/Download.vue index 860f91c..e3bc7a8 100644 --- a/app/src/Download.vue +++ b/app/src/Download.vue @@ -19,7 +19,16 @@ i.fa.fa-key | decrypt .panel.panel-primary(v-if='!needsPassword') - .panel-heading Files + .panel-heading + strong Files + div.pull-right(style="margin-top:-5px;") + 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 + 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 @@ -32,10 +41,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 +51,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 +59,7 @@ export default { name: 'app', components: { FileIcon, Clipboard }, + data () { return { files: [], @@ -63,6 +72,13 @@ host: document.location.protocol + '//' + document.location.host } }, + + computed: { + downloadsAvailable: function() { + return this.files.filter(f => !f.downloaded || f.metadata.retention !== 'one-time').length > 0 + } + }, + methods: { download(file) { if(file.downloaded && file.metadata.retention === 'one-time') { @@ -73,6 +89,20 @@ file.downloaded = true; }, + 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() + '.' + format; + + 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 f618729..184c7f3 100644 --- a/lib/endpoints.js +++ b/lib/endpoints.js @@ -11,7 +11,10 @@ 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 zlib = require('zlib'); const errorPage = fs.readFileSync(path.join(__dirname, '../public/html/error.html')).toString(); const store = new Store(config.uploadDir); @@ -72,11 +75,64 @@ 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.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(!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; + } + debug(`Download Bucket ${sid}`); + + 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(format === 'zip' ? 'zip' : 'tar'); + 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} + ); + }); + + if(format === 'tar.gz') { + archive.pipe(zlib.createGzip()).pipe(res); + } else { + 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",