mirror of
https://github.com/BobbyWibowo/lolisafe.git
synced 2024-12-15 08:56:21 +00:00
479db54cd3
* Possible performance improvement. Some bulk db queries will now be executed in a single query instead of spawning multiple async task for each query. This is sorta experimental though, use it at your own risk (though I'll use it right away at safe.fiery.me). * It's now possible for root user to add files to other users' albums through the API route. I don't plan on allowing root user to list other users' album list from the dashboard, I just thought that there'd be no harm in extending the API a little bit. * Kinda better error logging for uncaught exception and unhandled rejection. Their stack trace should be logged now.
508 lines
14 KiB
JavaScript
508 lines
14 KiB
JavaScript
const config = require('./../config')
|
|
const db = require('knex')(config.database)
|
|
const EventEmitter = require('events')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const randomstring = require('randomstring')
|
|
const utils = require('./utilsController')
|
|
const Zip = require('jszip')
|
|
|
|
const albumsController = {}
|
|
|
|
const maxTries = config.uploads.maxTries || 1
|
|
const homeDomain = config.homeDomain || config.domain
|
|
const uploadsDir = path.join(__dirname, '..', config.uploads.folder)
|
|
const zipsDir = path.join(uploadsDir, 'zips')
|
|
const zipMaxTotalSize = config.cloudflare.zipMaxTotalSize
|
|
const zipMaxTotalSizeBytes = parseInt(config.cloudflare.zipMaxTotalSize) * 1000000
|
|
|
|
albumsController.zipEmitters = new Map()
|
|
|
|
class ZipEmitter extends EventEmitter {
|
|
constructor (identifier) {
|
|
super()
|
|
this.identifier = identifier
|
|
this.once('done', () => albumsController.zipEmitters.delete(this.identifier))
|
|
}
|
|
}
|
|
|
|
albumsController.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(fields, ['timestamp', 'identifier', 'editedAt', 'download', 'public'])
|
|
}
|
|
|
|
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 ids = []
|
|
for (const album of albums) {
|
|
album.date = utils.getPrettyDate(new Date(album.timestamp * 1000))
|
|
album.download = album.download !== 0
|
|
album.public = album.public !== 0
|
|
|
|
ids.push(album.id)
|
|
}
|
|
|
|
const files = await db.table('files')
|
|
.whereIn('albumid', ids)
|
|
.select('albumid')
|
|
const albumsCount = {}
|
|
|
|
for (const id of ids) { albumsCount[id] = 0 }
|
|
for (const file of files) { albumsCount[file.albumid] += 1 }
|
|
for (const album of albums) { album.files = albumsCount[album.id] }
|
|
|
|
return res.json({ success: true, albums, homeDomain })
|
|
}
|
|
|
|
albumsController.create = async (req, res, next) => {
|
|
const user = await utils.authorize(req, res)
|
|
if (!user) { return }
|
|
|
|
const name = req.body.name
|
|
if (name === undefined || name === '') {
|
|
return res.json({ success: false, description: 'No album name specified.' })
|
|
}
|
|
|
|
const album = await db.table('albums')
|
|
.where({
|
|
name,
|
|
enabled: 1,
|
|
userid: user.id
|
|
})
|
|
.first()
|
|
|
|
if (album) {
|
|
return res.json({ success: false, description: 'There\'s already an album with that name.' })
|
|
}
|
|
|
|
const identifier = await albumsController.getUniqueRandomName()
|
|
.catch(error => {
|
|
res.json({ success: false, description: error.toString() })
|
|
})
|
|
if (!identifier) { return }
|
|
|
|
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
|
|
})
|
|
|
|
return res.json({ success: true, id: ids[0] })
|
|
}
|
|
|
|
albumsController.getUniqueRandomName = () => {
|
|
return new Promise((resolve, reject) => {
|
|
const select = i => {
|
|
const identifier = randomstring.generate(config.uploads.albumIdentifierLength)
|
|
db.table('albums')
|
|
.where('identifier', identifier)
|
|
.then(rows => {
|
|
if (!rows || !rows.length) { return resolve(identifier) }
|
|
console.log(`An album with identifier ${identifier} already exists (${++i}/${maxTries}).`)
|
|
if (i < maxTries) { return select(i) }
|
|
// eslint-disable-next-line prefer-promise-reject-errors
|
|
return reject('Sorry, we could not allocate a unique random identifier. Try again?')
|
|
})
|
|
}
|
|
// Get us a unique random identifier
|
|
select(0)
|
|
})
|
|
}
|
|
|
|
albumsController.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.' })
|
|
}
|
|
|
|
let ids = []
|
|
let failed = []
|
|
if (purge) {
|
|
const files = await db.table('files')
|
|
.where({
|
|
albumid: id,
|
|
userid: user.id
|
|
})
|
|
|
|
ids = files.map(file => file.id)
|
|
failed = await utils.bulkDeleteFiles('id', ids, user)
|
|
|
|
if (failed.length === ids.length) {
|
|
return res.json({ success: false, description: 'Could not delete any of the files associated with the album.' })
|
|
}
|
|
}
|
|
|
|
await db.table('albums')
|
|
.where({
|
|
id,
|
|
userid: user.id
|
|
})
|
|
.update('enabled', 0)
|
|
|
|
const identifier = await db.table('albums')
|
|
.select('identifier')
|
|
.where({
|
|
id,
|
|
userid: user.id
|
|
})
|
|
.first()
|
|
.then(row => row.identifier)
|
|
|
|
// Unlink zip archive of the album if it exists
|
|
const zipPath = path.join(zipsDir, `${identifier}.zip`)
|
|
fs.unlink(zipPath, error => {
|
|
if (error && error.code !== 'ENOENT') {
|
|
console.error(error)
|
|
return res.json({ success: false, description: error.toString(), failed })
|
|
}
|
|
res.json({ success: true, failed })
|
|
})
|
|
}
|
|
|
|
albumsController.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 = req.body.name
|
|
if (name === undefined || name === '') {
|
|
return res.json({ success: false, description: 'No name specified.' })
|
|
}
|
|
|
|
const album = await db.table('albums')
|
|
.where({
|
|
name,
|
|
userid: user.id,
|
|
enabled: 1
|
|
})
|
|
.first()
|
|
|
|
if (album && (album.id !== id)) {
|
|
return res.json({ success: false, description: 'Name already in use.' })
|
|
} else if (req._old && (album.id === id)) {
|
|
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)
|
|
})
|
|
|
|
if (req.body.requestLink) {
|
|
const oldIdentifier = await db.table('albums')
|
|
.select('identifier')
|
|
.where({
|
|
id,
|
|
userid: user.id
|
|
})
|
|
.first()
|
|
.then(row => row.identifier)
|
|
|
|
const identifier = await albumsController.getUniqueRandomName()
|
|
.catch(error => {
|
|
res.json({ success: false, description: error.toString() })
|
|
})
|
|
if (!identifier) { return }
|
|
|
|
await db.table('albums')
|
|
.where({
|
|
id,
|
|
userid: user.id
|
|
})
|
|
.update('identifier', identifier)
|
|
|
|
// Rename zip archive of the album if it exists
|
|
const zipPath = path.join(zipsDir, `${oldIdentifier}.zip`)
|
|
return fs.access(zipPath, error => {
|
|
if (error) { return res.json({ success: true, identifier }) }
|
|
fs.rename(zipPath, path.join(zipsDir, `${identifier}.zip`), error => {
|
|
if (!error) { return res.json({ success: true, identifier }) }
|
|
console.error(error)
|
|
res.json({ success: false, description: error.toString() })
|
|
})
|
|
})
|
|
}
|
|
|
|
return res.json({ success: true, name })
|
|
}
|
|
|
|
albumsController.rename = async (req, res, next) => {
|
|
req._old = true
|
|
req.body = { name: req.body.name }
|
|
return albumsController.edit(req, res, next)
|
|
}
|
|
|
|
albumsController.get = async (req, res, next) => {
|
|
// TODO:
|
|
const identifier = req.params.identifier
|
|
if (identifier === undefined) {
|
|
return res.status(401).json({ success: false, description: 'No identifier provided.' })
|
|
}
|
|
|
|
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(401).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 ext = path.extname(file.name).toLowerCase()
|
|
if ((config.uploads.generateThumbs.image && utils.imageExtensions.includes(ext)) || (config.uploads.generateThumbs.video && utils.videoExtensions.includes(ext))) {
|
|
file.thumb = `${config.domain}/thumbs/${file.name.slice(0, -ext.length)}.png`
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
title,
|
|
count: files.length,
|
|
files
|
|
})
|
|
}
|
|
|
|
albumsController.generateZip = async (req, res, next) => {
|
|
const versionString = parseInt(req.query.v)
|
|
const download = (filePath, fileName) => {
|
|
const headers = { 'Access-Control-Allow-Origin': '*' }
|
|
if (versionString > 0) {
|
|
// Cache-Control header is useful when using CDN (max-age: 30 days)
|
|
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.' })
|
|
}
|
|
|
|
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 ((!versionString || versionString <= 0) && album.editedAt) {
|
|
return res.redirect(`${album.identifier}?v=${album.editedAt}`)
|
|
}
|
|
|
|
if (album.zipGeneratedAt > album.editedAt) {
|
|
const filePath = path.join(zipsDir, `${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 (albumsController.zipEmitters.has(identifier)) {
|
|
console.log(`Waiting previous zip task for album: ${identifier}.`)
|
|
return albumsController.zipEmitters.get(identifier).once('done', (filePath, fileName, json) => {
|
|
if (filePath && fileName) {
|
|
download(filePath, fileName)
|
|
} else if (json) {
|
|
res.json(json)
|
|
}
|
|
})
|
|
}
|
|
|
|
albumsController.zipEmitters.set(identifier, new ZipEmitter(identifier))
|
|
|
|
console.log(`Starting zip task for album: ${identifier}.`)
|
|
const files = await db.table('files')
|
|
.select('name', 'size')
|
|
.where('albumid', album.id)
|
|
if (files.length === 0) {
|
|
console.log(`Finished zip task for album: ${identifier} (no files).`)
|
|
const json = { success: false, description: 'There are no files in the album.' }
|
|
albumsController.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) {
|
|
console.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}).`
|
|
}
|
|
albumsController.zipEmitters.get(identifier).emit('done', null, null, json)
|
|
return res.json(json)
|
|
}
|
|
}
|
|
|
|
const zipPath = path.join(zipsDir, `${album.identifier}.zip`)
|
|
const archive = new Zip()
|
|
|
|
let iteration = 0
|
|
for (const file of files) {
|
|
fs.readFile(path.join(uploadsDir, file.name), (error, data) => {
|
|
if (error) {
|
|
console.error(error)
|
|
} else {
|
|
archive.file(file.name, data)
|
|
}
|
|
|
|
iteration++
|
|
if (iteration === files.length) {
|
|
archive
|
|
.generateNodeStream({
|
|
type: 'nodebuffer',
|
|
streamFiles: true,
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 1 }
|
|
})
|
|
.pipe(fs.createWriteStream(zipPath))
|
|
.on('finish', async () => {
|
|
console.log(`Finished zip task for album: ${identifier} (success).`)
|
|
await db.table('albums')
|
|
.where('id', album.id)
|
|
.update('zipGeneratedAt', Math.floor(Date.now() / 1000))
|
|
|
|
const filePath = path.join(zipsDir, `${identifier}.zip`)
|
|
const fileName = `${album.name}.zip`
|
|
|
|
albumsController.zipEmitters.get(identifier).emit('done', filePath, fileName)
|
|
return download(filePath, fileName)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
albumsController.addFiles = async (req, res, next) => {
|
|
const user = await utils.authorize(req, res)
|
|
if (!user) { return }
|
|
|
|
const ids = req.body.ids
|
|
if (!ids || !ids.length) {
|
|
return res.json({ success: false, description: 'No files specified.' })
|
|
}
|
|
|
|
let albumid = req.body.albumid
|
|
if (typeof albumid !== 'number') { albumid = parseInt(albumid) }
|
|
if (isNaN(albumid) || (albumid < 0)) { albumid = null }
|
|
|
|
const albumids = []
|
|
|
|
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 doesn\'t exist or it doesn\'t belong to the user.' })
|
|
}
|
|
|
|
albumids.push(albumid)
|
|
}
|
|
|
|
const files = await db.table('files')
|
|
.whereIn('id', ids)
|
|
.where(function () {
|
|
if (user.username !== 'root') {
|
|
this.where('userid', user.id)
|
|
}
|
|
})
|
|
|
|
const failed = ids.filter(id => !files.find(file => file.id === id))
|
|
|
|
const updateDb = await db.table('files')
|
|
.whereIn('id', files.map(file => file.id))
|
|
.update('albumid', albumid)
|
|
.catch(console.error)
|
|
|
|
if (!updateDb) {
|
|
return res.json({
|
|
success: false,
|
|
description: `Could not ${albumid === null ? 'add' : 'remove'} any files ${albumid === null ? 'to' : 'from'} the album.`
|
|
})
|
|
}
|
|
|
|
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))
|
|
.catch(console.error)
|
|
|
|
return res.json({ success: true, failed })
|
|
}
|
|
|
|
module.exports = albumsController
|