diff --git a/package.json b/package.json index abea0ca..6264da5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "node": ">=8.0.0" }, "dependencies": { + "adm-zip": "^0.4.11", "axios": "^0.18.0", "bcrypt": "^2.0.1", "body-parser": "^1.18.2", diff --git a/src/api/routes/albums/albumZipGET.js b/src/api/routes/albums/albumZipGET.js new file mode 100644 index 0000000..7a853cd --- /dev/null +++ b/src/api/routes/albums/albumZipGET.js @@ -0,0 +1,78 @@ +const Route = require('../../structures/Route'); +const config = require('../../../../config'); +const db = require('knex')(config.server.database); +const Util = require('../../utils/Util'); +const log = require('../../utils/Log'); +const path = require('path'); +const jetpack = require('fs-jetpack'); + +class albumGET extends Route { + constructor() { + super('/album/:identifier/zip', 'get', { bypassAuth: true }); + } + + async run(req, res) { + const { identifier } = req.params; + if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' }); + + /* + Make sure it exists and it's enabled + */ + const link = await db.table('links').where({ identifier, enabled: true }).first(); + if (!link) return res.status(400).json({ message: 'The identifier supplied could not be found' }); + + /* + Same with the album, just to make sure is not a deleted album and a leftover link + */ + const album = await db.table('albums').where('id', link.albumId).first(); + if (!album) return res.status(400).json({ message: 'Album not found' }); + + /* + If the date when the album was zipped is greater than the album's last edit, we just send the zip to the user + */ + if (album.zippedAt > album.editedAt) { + const filePath = path.join(__dirname, '..', '..', '..', '..', config.uploads.uploadFolder, 'zips', `${album.userId}-${album.id}.zip`); + const exists = await jetpack.existsAsync(filePath); + /* + Make sure the file exists just in case, and if not, continue to it's generation. + */ + if (exists) { + const fileName = `lolisafe-${identifier}.zip`; + return res.download(filePath, fileName); + } + } + + /* + Grab the files in a very unoptimized way. (This should be a join between both tables) + */ + const fileList = await db.table('albumsFiles').where('albumId', link.albumId).select('fileId'); + + /* + If there are no files, stop here + */ + if (!fileList) return res.status(400).json({ message: 'Can\'t download an empty album' }); + + /* + Get the actual files + */ + const fileIds = fileList.map(el => el.fileId); + const files = await db.table('files') + .whereIn('id', fileIds) + .select('name'); + const filesToZip = files.map(el => el.name); + + try { + Util.createZip(filesToZip, album); + await db.table('albums').where('id', link.albumId).update('zippedAt', db.fn.now()); + + const filePath = path.join(__dirname, '..', '..', '..', '..', config.uploads.uploadFolder, 'zips', `${album.userId}-${album.id}.zip`); + const fileName = `lolisafe-${identifier}.zip`; + return res.download(filePath, fileName); + } catch (error) { + log.error(error); + return res.status(500).json({ message: 'There was a problem downloading the album' }); + } + } +} + +module.exports = albumGET; diff --git a/src/api/routes/albums/link/linkPOST.js b/src/api/routes/albums/link/linkPOST.js index 4b24eae..1edf891 100644 --- a/src/api/routes/albums/link/linkPOST.js +++ b/src/api/routes/albums/link/linkPOST.js @@ -9,7 +9,7 @@ class linkPOST extends Route { super('/album/link/new', 'post'); } - async run(req, res) { + async run(req, res, user) { if (!req.body) return res.status(400).json({ message: 'No body provided' }); const { albumId } = req.body; if (!albumId) return res.status(400).json({ message: 'No album provided' }); @@ -35,6 +35,7 @@ class linkPOST extends Route { try { await db.table('links').insert({ identifier, + userId: user.id, albumId, enabled: true, enableDownload: true, diff --git a/src/api/structures/Database.js b/src/api/structures/Database.js index dc26afe..76ea006 100644 --- a/src/api/structures/Database.js +++ b/src/api/structures/Database.js @@ -34,6 +34,7 @@ class Database { // table.string('identifier'); // table.boolean('enabled'); // table.boolean('enableDownload').defaultTo(true); + table.timestamp('zippedAt'); table.timestamp('createdAt'); table.timestamp('editedAt'); }); @@ -57,6 +58,7 @@ class Database { if (!await db.schema.hasTable('links')) { await db.schema.createTable('links', table => { table.increments(); + table.integer('userId'); table.integer('albumId'); table.string('identifier'); table.integer('views').defaultTo(0); diff --git a/src/api/structures/Server.js b/src/api/structures/Server.js index ae4b678..0b05570 100644 --- a/src/api/structures/Server.js +++ b/src/api/structures/Server.js @@ -24,6 +24,10 @@ class Server { this.server.use(helmet()); this.server.use(cors({ allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId'] })); this.server.use((req, res, next) => { + /* + This bypasses the headers.accept for album download, since it's accesed directly through the browser. + */ + if (req.url.includes('/api/album/') && req.url.includes('/zip') && req.method === 'GET') return next(); if (req.headers.accept === 'application/vnd.lolisafe.json') return next(); return res.status(405).json({ message: 'Incorrect `Accept` header provided' }); }); diff --git a/src/api/utils/Util.js b/src/api/utils/Util.js index 617b38f..52cfb03 100644 --- a/src/api/utils/Util.js +++ b/src/api/utils/Util.js @@ -9,6 +9,7 @@ const log = require('../utils/Log'); const crypto = require('crypto'); const sharp = require('sharp'); const ffmpeg = require('fluent-ffmpeg'); +const Zip = require('adm-zip'); const imageExtensions = ['.jpg', '.jpeg', '.bmp', '.gif', '.png', '.webp']; const videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov']; @@ -183,6 +184,18 @@ class Util { return user; }); } + + static createZip(files, album) { + try { + const zip = new Zip(); + for (const file of files) { + zip.addLocalFile(path.join(__dirname, '..', '..', '..', config.uploads.uploadFolder, file)); + } + zip.writeZip(path.join(__dirname, '..', '..', '..', config.uploads.uploadFolder, 'zips', `${album.userId}-${album.id}.zip`)); + } catch (error) { + log.error(error); + } + } } module.exports = Util; diff --git a/src/site/views/PublicAlbum.vue b/src/site/views/PublicAlbum.vue index 534c185..e156698 100644 --- a/src/site/views/PublicAlbum.vue +++ b/src/site/views/PublicAlbum.vue @@ -24,6 +24,8 @@

{{ name }}

Serving {{ files.length }} files

+ Download Album
@@ -57,17 +59,20 @@ export default { async getInitialData({ route, store }) { try { const res = await axios.get(`${config.baseURL}/album/${route.params.identifier}`); + const downloadLink = res.data.downloadEnabled ? `${config.baseURL}/album/${route.params.identifier}/zip` : null; return { name: res.data.name, downloadEnabled: res.data.downloadEnabled, - files: res.data.files + files: res.data.files, + downloadLink }; } catch (error) { console.error(error); return { name: null, downloadEnabled: false, - files: [] + files: [], + downloadLink: null }; } }, @@ -100,6 +105,11 @@ export default { location: window.location.href }); }, - methods: {} + methods: { + async downloadAlbum() { + const response = await axios.get(`${config.baseURL}/album/${this.$route.params.identifier}/zip`); + console.log(response.data); + } + } }; diff --git a/yarn.lock b/yarn.lock index 60598b4..4ffcfab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -819,6 +819,10 @@ acorn@^5.0.0, acorn@^5.5.0, acorn@^5.6.2: version "5.7.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" +adm-zip@^0.4.11: + version "0.4.11" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.11.tgz#2aa54c84c4b01a9d0fb89bb11982a51f13e3d62a" + ajv-errors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59"