const config = require('./../config') const db = require('knex')(config.database) const EventEmitter = require('events') const fs = require('fs') const logger = require('./../logger') const path = require('path') const paths = require('./pathsController') const randomstring = require('randomstring') const utils = require('./utilsController') const Zip = require('jszip') const self = { onHold: new Set() } const homeDomain = config.homeDomain || config.domain const zipMaxTotalSize = parseInt(config.cloudflare.zipMaxTotalSize) const zipMaxTotalSizeBytes = config.cloudflare.zipMaxTotalSize * 1000000 const zipOptions = config.uploads.jsZipOptions // Force 'type' option to 'nodebuffer' zipOptions.type = 'nodebuffer' // Apply fallbacks for missing config values if (zipOptions.streamFiles === undefined) zipOptions.streamFiles = true if (zipOptions.compression === undefined) zipOptions.compression = 'DEFLATE' if (zipOptions.compressionOptions === undefined || zipOptions.compressionOptions.level === undefined) zipOptions.compressionOptions = { level: 1 } self.zipEmitters = new Map() class ZipEmitter extends EventEmitter { constructor (identifier) { super() this.identifier = identifier this.once('done', () => self.zipEmitters.delete(this.identifier)) } } self.getUniqueRandomName = async () => { for (let i = 0; i < utils.idMaxTries; i++) { const identifier = randomstring.generate(config.uploads.albumIdentifierLength) if (self.onHold.has(identifier)) continue // Put token on-hold (wait for it to be inserted to DB) self.onHold.add(identifier) const album = await db.table('albums') .where('identifier', identifier) .select('id') .first() if (album) { self.onHold.delete(identifier) logger.log(`Album with identifier ${identifier} already exists (${i + 1}/${utils.idMaxTries}).`) continue } return identifier } throw 'Sorry, we could not allocate a unique random identifier. Try again?' } self.list = async (req, res, next) => { const user = await utils.authorize(req, res) if (!user) return let fields = ['id', 'name'] if (req.params.sidebar === undefined) fields = fields.concat(['timestamp', 'identifier', 'editedAt', 'download', 'public', 'description']) const albums = await db.table('albums') .select(fields) .where({ enabled: 1, userid: user.id }) if (req.params.sidebar !== undefined) return res.json({ success: true, albums }) const albumids = {} for (const album of albums) { album.download = album.download !== 0 album.public = album.public !== 0 album.files = 0 // Map by IDs albumids[album.id] = album } const files = await db.table('files') .whereIn('albumid', Object.keys(albumids)) .select('albumid') // Increment files count for (const file of files) if (albumids[file.albumid]) albumids[file.albumid].files++ return res.json({ success: true, albums, homeDomain }) } self.create = async (req, res, next) => { const user = await utils.authorize(req, res) if (!user) return const name = typeof req.body.name === 'string' ? utils.escape(req.body.name.trim()) : '' if (!name) return res.json({ success: false, description: 'No album name specified.' }) try { const album = await db.table('albums') .where({ name, enabled: 1, userid: user.id }) .first() if (album) return res.json({ success: false, description: 'There is already an album with that name.' }) const identifier = await self.getUniqueRandomName() const ids = await db.table('albums').insert({ name, enabled: 1, userid: user.id, identifier, timestamp: Math.floor(Date.now() / 1000), editedAt: 0, zipGeneratedAt: 0, download: (req.body.download === false || req.body.download === 0) ? 0 : 1, public: (req.body.public === false || req.body.public === 0) ? 0 : 1, description: typeof req.body.description === 'string' ? utils.escape(req.body.description.trim()) : '' }) utils.invalidateStatsCache('albums') self.onHold.delete(identifier) return res.json({ success: true, id: ids[0] }) } catch (error) { logger.error(error) return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' }) } } self.delete = async (req, res, next) => { const user = await utils.authorize(req, res) if (!user) return const id = req.body.id const purge = req.body.purge if (id === undefined || id === '') return res.json({ success: false, description: 'No album specified.' }) try { if (purge) { const files = await db.table('files') .where({ albumid: id, userid: user.id }) if (files.length) { const ids = files.map(file => file.id) const failed = await utils.bulkDeleteFromDb('id', ids, user) if (failed.length) return res.json({ success: false, failed }) } } await db.table('albums') .where({ id, userid: user.id }) .update('enabled', 0) utils.invalidateStatsCache('albums') const identifier = await db.table('albums') .select('identifier') .where({ id, userid: user.id }) .first() .then(row => row.identifier) await paths.unlink(path.join(paths.zips, `${identifier}.zip`)) } catch (error) { if (error && error.code !== 'ENOENT') { logger.error(error) return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' }) } } return res.json({ success: true }) } self.edit = async (req, res, next) => { const user = await utils.authorize(req, res) if (!user) return const id = parseInt(req.body.id) if (isNaN(id)) return res.json({ success: false, description: 'No album specified.' }) const name = typeof req.body.name === 'string' ? utils.escape(req.body.name.trim()) : '' if (!name) return res.json({ success: false, description: 'No name specified.' }) try { const album = await db.table('albums') .where({ id, userid: user.id, enabled: 1 }) .first() if (!album) return res.json({ success: false, description: 'Could not get album with the specified ID.' }) else if (album.id !== id) return res.json({ success: false, description: 'Name already in use.' }) else if (req._old && (album.id === id)) // Old rename API return res.json({ success: false, description: 'You did not specify a new name.' }) await db.table('albums') .where({ id, userid: user.id }) .update({ name, download: Boolean(req.body.download), public: Boolean(req.body.public), description: typeof req.body.description === 'string' ? utils.escape(req.body.description.trim()) : '' }) utils.invalidateStatsCache('albums') if (!req.body.requestLink) return res.json({ success: true, name }) const oldIdentifier = album.identifier const newIdentifier = await self.getUniqueRandomName() await db.table('albums') .where({ id, userid: user.id }) .update('identifier', newIdentifier) utils.invalidateStatsCache('albums') self.onHold.delete(newIdentifier) // Rename zip archive of the album if it exists try { const oldZip = path.join(paths.zips, `${oldIdentifier}.zip`) // await paths.access(oldZip) const newZip = path.join(paths.zips, `${newIdentifier}.zip`) await paths.rename(oldZip, newZip) } catch (err) { // Re-throw error if (err.code !== 'ENOENT') throw err } return res.json({ success: true, identifier: newIdentifier }) } catch (error) { logger.error(error) return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' }) } } self.rename = async (req, res, next) => { req._old = true req.body = { name: req.body.name } return self.edit(req, res, next) } self.get = async (req, res, next) => { const identifier = req.params.identifier if (identifier === undefined) return res.status(401).json({ success: false, description: 'No identifier provided.' }) try { const album = await db.table('albums') .where({ identifier, enabled: 1 }) .first() if (!album) return res.json({ success: false, description: 'Album not found.' }) else if (album.public === 0) return res.status(403).json({ success: false, description: 'This album is not available for public.' }) const title = album.name const files = await db.table('files') .select('name') .where('albumid', album.id) .orderBy('id', 'DESC') for (const file of files) { file.file = `${config.domain}/${file.name}` const extname = utils.extname(file.name) if (utils.mayGenerateThumb(extname)) file.thumb = `${config.domain}/thumbs/${file.name.slice(0, -extname.length)}.png` } return res.json({ success: true, title, count: files.length, files }) } catch (error) { logger.error(error) return res.status(500).json({ success: false, description: 'An unexpected error occcured. Try again?' }) } } self.generateZip = async (req, res, next) => { const versionString = parseInt(req.query.v) const download = (filePath, fileName) => { const headers = {} if (config.cacheControl && versionString > 0) { headers['Access-Control-Allow-Origin'] = '*' headers['Cache-Control'] = 'public, max-age=2592000, must-revalidate, proxy-revalidate, immutable, stale-while-revalidate=86400, stale-if-error=604800' } return res.download(filePath, fileName, { headers }) } const identifier = req.params.identifier if (identifier === undefined) return res.status(401).json({ success: false, description: 'No identifier provided.' }) if (!config.uploads.generateZips) return res.status(401).json({ success: false, description: 'Zip generation disabled.' }) try { const album = await db.table('albums') .where({ identifier, enabled: 1 }) .first() if (!album) return res.json({ success: false, description: 'Album not found.' }) else if (album.download === 0) return res.json({ success: false, description: 'Download for this album is disabled.' }) if ((isNaN(versionString) || versionString <= 0) && album.editedAt) return res.redirect(`${album.identifier}?v=${album.editedAt}`) if (album.zipGeneratedAt > album.editedAt) { const filePath = path.join(paths.zips, `${identifier}.zip`) const exists = await new Promise(resolve => fs.access(filePath, error => resolve(!error))) if (exists) { const fileName = `${album.name}.zip` return download(filePath, fileName) } } if (self.zipEmitters.has(identifier)) { logger.log(`Waiting previous zip task for album: ${identifier}.`) return self.zipEmitters.get(identifier).once('done', (filePath, fileName, json) => { if (filePath && fileName) download(filePath, fileName) else if (json) res.json(json) }) } self.zipEmitters.set(identifier, new ZipEmitter(identifier)) logger.log(`Starting zip task for album: ${identifier}.`) const files = await db.table('files') .select('name', 'size') .where('albumid', album.id) if (files.length === 0) { logger.log(`Finished zip task for album: ${identifier} (no files).`) const json = { success: false, description: 'There are no files in the album.' } self.zipEmitters.get(identifier).emit('done', null, null, json) return res.json(json) } if (zipMaxTotalSize) { const totalSizeBytes = files.reduce((accumulator, file) => accumulator + parseInt(file.size), 0) if (totalSizeBytes > zipMaxTotalSizeBytes) { logger.log(`Finished zip task for album: ${identifier} (size exceeds).`) const json = { success: false, description: `Total size of all files in the album exceeds the configured limit (${zipMaxTotalSize} MB).` } self.zipEmitters.get(identifier).emit('done', null, null, json) return res.json(json) } } const zipPath = path.join(paths.zips, `${album.identifier}.zip`) const archive = new Zip() try { for (const file of files) { const data = await paths.readFile(path.join(paths.uploads, file.name)) archive.file(file.name, data) } await new Promise((resolve, reject) => { archive.generateNodeStream(zipOptions) .pipe(fs.createWriteStream(zipPath)) .on('error', error => reject(error)) .on('finish', () => resolve()) }) } catch (error) { logger.error(error) return res.status(500).json({ success: 'false', description: error.toString() }) } logger.log(`Finished zip task for album: ${identifier} (success).`) await db.table('albums') .where('id', album.id) .update('zipGeneratedAt', Math.floor(Date.now() / 1000)) utils.invalidateStatsCache('albums') const filePath = path.join(paths.zips, `${identifier}.zip`) const fileName = `${album.name}.zip` self.zipEmitters.get(identifier).emit('done', filePath, fileName) return download(filePath, fileName) } catch (error) { logger.error(error) return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' }) } } self.addFiles = async (req, res, next) => { const user = await utils.authorize(req, res) if (!user) return const ids = req.body.ids if (!Array.isArray(ids) || !ids.length) return res.json({ success: false, description: 'No files specified.' }) let albumid = parseInt(req.body.albumid) if (isNaN(albumid) || albumid < 0) albumid = null let failed = [] const albumids = [] try { if (albumid !== null) { const album = await db.table('albums') .where('id', albumid) .where(function () { if (user.username !== 'root') this.where('userid', user.id) }) .first() if (!album) return res.json({ success: false, description: 'Album does not exist or it does not belong to the user.' }) albumids.push(albumid) } const files = await db.table('files') .whereIn('id', ids) .where('userid', user.id) failed = ids.filter(id => !files.find(file => file.id === id)) await db.table('files') .whereIn('id', files.map(file => file.id)) .update('albumid', albumid) files.forEach(file => { if (file.albumid && !albumids.includes(file.albumid)) albumids.push(file.albumid) }) await db.table('albums') .whereIn('id', albumids) .update('editedAt', Math.floor(Date.now() / 1000)) return res.json({ success: true, failed }) } catch (error) { logger.error(error) if (failed.length === ids.length) return res.json({ success: false, description: `Could not ${albumid === null ? 'add' : 'remove'} any files ${albumid === null ? 'to' : 'from'} the album.` }) else return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' }) } } module.exports = self