filesafe/controllers/albumsController.js
Bobby Wibowo 02e2e402c3
!!! MASSIVE OVERHAUL !!!
As the title says, this commit is a massive overhaul.
I've rewritten/restrucuted almost everything in the controller scripts.
Because of that, there's a considerable possibility that I've broken
something somewhere.

Notable changes:

Added temporary uploads.

Removed file name length changer from dashboard,
in favor of an equivalent in homepage config tab.
This allows non-registered users to also set file name length.

A bunch of other undocmented stuff.
I don't know, I'm too tired to remember them all.
2019-09-08 08:56:29 +07:00

543 lines
15 KiB
JavaScript

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