filesafe/controllers/albumsController.js
2023-09-06 18:58:15 +07:00

771 lines
22 KiB
JavaScript

const contentDisposition = require('content-disposition')
const EventEmitter = require('events')
const fsPromises = require('fs/promises')
const jetpack = require('fs-jetpack')
const path = require('path')
const randomstring = require('randomstring')
const Zip = require('jszip')
const paths = require('./pathsController')
const perms = require('./permissionController')
const utils = require('./utilsController')
const ServeStatic = require('./handlers/ServeStatic')
const ClientError = require('./utils/ClientError')
const ServerError = require('./utils/ServerError')
const config = require('./utils/ConfigManager')
const logger = require('./../logger')
const self = {
// Don't forget to update max length of text inputs in
// home.js & dashboard.js when changing these values
titleMaxLength: 70,
descMaxLength: 4000,
onHold: new Set() // temporarily held random album identifiers
}
/** Preferences */
const homeDomain = config.homeDomain || config.domain
const albumsPerPage = config.dashboard
? Math.max(Math.min(config.dashboard.albumsPerPage || 0, 100), 1)
: 25
const zipMaxTotalSize = parseInt(config.cloudflare.zipMaxTotalSize)
const zipMaxTotalSizeBytes = zipMaxTotalSize * 1e6
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: 1 }
self.zipEmitters = new Map()
class ZipEmitter extends EventEmitter {
constructor (identifier) {
super()
this.identifier = identifier
this.once('done', () => self.zipEmitters.delete(this.identifier))
}
}
// ServeStatic instance to handle downloading of album ZIP archives
const serveAlbumZipInstance = new ServeStatic(paths.zips)
self.getUniqueAlbumIdentifier = async res => {
for (let i = 0; i < utils.idMaxTries; i++) {
const identifier = randomstring.generate(config.uploads.albumIdentifierLength)
if (self.onHold.has(identifier)) {
logger.debug(`Identifier ${identifier} is currently held by another album (${i + 1}/${utils.idMaxTries}).`)
continue
}
// Put token on-hold (wait for it to be inserted to DB)
self.onHold.add(identifier)
const album = await utils.db.table('albums')
.where('identifier', identifier)
.select('id')
.first()
if (album) {
self.onHold.delete(identifier)
logger.debug(`Album with identifier ${identifier} already exists (${i + 1}/${utils.idMaxTries}).`)
continue
}
/*
if (utils.devmode) {
logger.debug(`albums.onHold: ${utils.inspect(self.onHold)}`)
}
*/
// Unhold identifier once the Response has been sent
if (res) {
// Keep in an array for future-proofing
// if a single Request needs to generate multiple album identifiers
if (!res.locals.identifiers) {
res.locals.identifiers = []
res.once('finish', () => { self.unholdAlbumIdentifiers(res) })
}
res.locals.identifiers.push(identifier)
}
return identifier
}
throw new ServerError('Failed to allocate a unique identifier for the album. Try again?')
}
self.unholdAlbumIdentifiers = res => {
if (!res.locals.identifiers) return
for (const identifier of res.locals.identifiers) {
self.onHold.delete(identifier)
/*
if (utils.devmode) {
logger.debug(`albums.onHold: ${utils.inspect(self.onHold)} -> ${utils.inspect(identifier)}`)
}
*/
}
delete res.locals.identifiers
}
self.list = async (req, res) => {
const all = req.headers.all === '1'
const simple = req.headers.simple
const ismoderator = perms.is(req.locals.user, 'moderator')
if (all && !ismoderator) {
return res.status(403).end()
}
const filter = function () {
if (!all) {
this.where({
enabled: 1,
userid: req.locals.user.id
})
}
}
// Base result object
const result = { success: true, albums: [], albumsPerPage, count: 0, homeDomain }
// If simple listing (for dashboard sidebar)
if (simple) {
result.albums = await utils.db.table('albums')
.where(filter)
.select('id', 'name')
result.count = result.albums.length
return res.json(result)
}
// Query albums count for pagination
result.count = await utils.db.table('albums')
.where(filter)
.count('id as count')
.then(rows => rows[0].count)
if (!result.count) {
return res.json(result)
}
let offset = req.path_parameters && Number(req.path_parameters.page)
if (isNaN(offset)) {
offset = 0
} else if (offset < 0) {
offset = Math.max(0, Math.ceil(result.count / albumsPerPage) + offset)
}
const fields = ['id', 'name', 'identifier', 'enabled', 'timestamp', 'editedAt', 'zipGeneratedAt', 'download', 'public', 'description']
if (all) {
fields.push('userid')
}
result.albums = await utils.db.table('albums')
.where(filter)
.limit(albumsPerPage)
.offset(albumsPerPage * offset)
.select(fields)
const albumids = {}
for (const album of result.albums) {
album.download = album.download !== 0
album.public = album.public !== 0
album.uploads = 0
album.size = 0
album.zipSize = null
album.descriptionHtml = album.description
? utils.md.instance.render(album.description)
: ''
// Map by IDs
albumids[album.id] = album
// Get ZIP size
if (album.zipGeneratedAt) {
const filePath = path.join(paths.zips, `${album.identifier}.zip`)
const stats = await jetpack.inspectAsync(filePath)
if (stats) {
album.zipSize = stats.size
}
}
}
const uploads = await utils.db.table('files')
.whereIn('albumid', Object.keys(albumids))
.select('albumid', 'size')
for (const upload of uploads) {
if (albumids[upload.albumid]) {
albumids[upload.albumid].uploads++
albumids[upload.albumid].size += parseInt(upload.size)
}
}
// If we are not listing all albums, send response
if (!all) {
return res.json(result)
}
// Otherwise proceed to querying usernames
const userids = result.albums
.map(album => album.userid)
.filter(utils.filterUniquifySqlArray)
// If there are no albums attached to a registered user, send response
if (!userids.length) {
return res.json(result)
}
// Query usernames of user IDs from currently selected files
const usersTable = await utils.db.table('users')
.whereIn('id', userids)
.select('id', 'username')
result.users = {}
for (const user of usersTable) {
result.users[user.id] = user.username
}
return res.json(result)
}
self.create = async (req, res) => {
const name = typeof req.body.name === 'string'
? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
: ''
if (!name) {
throw new ClientError('No album name specified.')
}
const album = await utils.db.table('albums')
.where({
name,
enabled: 1,
userid: req.locals.user.id
})
.first()
if (album) {
throw new ClientError('Album name already in use.', { statusCode: 403 })
}
const identifier = await self.getUniqueAlbumIdentifier(res)
const ids = await utils.db.table('albums')
.insert({
name,
enabled: 1,
userid: req.locals.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().substring(0, self.descMaxLength))
: ''
})
utils.invalidateStatsCache('albums')
return res.json({ success: true, id: ids[0] })
}
self.delete = async (req, res) => {
// Re-map Request.body for .disable()
req.body.del = true
return self.disable(req, res)
}
self.disable = async (req, res) => {
const ismoderator = perms.is(req.locals.user, 'moderator')
const id = parseInt(req.body.id)
if (isNaN(id)) {
throw new ClientError('No album specified.')
}
const purge = req.body.purge
// Only allow moderators to delete other users' albums
const del = ismoderator ? req.body.del : false
const filter = function () {
this.where('id', id)
// Only allow moderators to disable other users' albums
if (!ismoderator) {
this.andWhere({
enabled: 1,
userid: req.locals.user.id
})
}
}
const album = await utils.db.table('albums')
.where(filter)
.first()
if (!album) {
throw new ClientError('Could not get album with the specified ID.')
}
if (purge) {
const files = await utils.db.table('files')
.where({
albumid: id,
userid: album.userid
})
if (files.length) {
const ids = files.map(file => file.id)
const failed = await utils.bulkDeleteFromDb('id', ids, req.locals.user)
if (failed.length) {
return res.json({ success: false, failed })
}
}
utils.invalidateStatsCache('uploads')
}
if (del) {
await utils.db.table('albums')
.where(filter)
.first()
.del()
} else {
await utils.db.table('albums')
.where(filter)
.first()
.update('enabled', 0)
}
utils.deleteStoredAlbumRenders([id])
utils.invalidateStatsCache('albums')
await jetpack.removeAsync(path.join(paths.zips, `${album.identifier}.zip`))
return res.json({ success: true })
}
self.edit = async (req, res) => {
const ismoderator = perms.is(req.locals.user, 'moderator')
const id = parseInt(req.body.id)
if (isNaN(id)) {
throw new ClientError('No album specified.')
}
const name = typeof req.body.name === 'string'
? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
: ''
if (!name) {
throw new ClientError('No album name specified.')
}
const filter = function () {
this.where('id', id)
// Only allow moderators to edit other users' albums
if (!ismoderator) {
this.andWhere({
enabled: 1,
userid: req.locals.user.id
})
}
}
const album = await utils.db.table('albums')
.where(filter)
.first()
if (!album) {
throw new ClientError('Could not get album with the specified ID.')
}
const albumNewState = (ismoderator && req.body.enabled !== undefined)
? Boolean(req.body.enabled)
: null
const nameInUse = await utils.db.table('albums')
.where({
name,
enabled: 1,
userid: req.locals.user.id
})
.whereNot('id', id)
.first()
if ((album.enabled || (albumNewState === true)) && nameInUse) {
if (req._legacy) {
// Legacy rename API (stick with 200 status code for this)
throw new ClientError('You did not specify a new name.', { statusCode: 200 })
} else {
throw new ClientError('Album name already in use.', { statusCode: 403 })
}
}
const update = {
name,
download: Boolean(req.body.download),
public: Boolean(req.body.public),
description: typeof req.body.description === 'string'
? utils.escape(req.body.description.trim().substring(0, self.descMaxLength))
: ''
}
if (albumNewState !== null) {
update.enabled = albumNewState
}
if (req.body.requestLink) {
update.identifier = await self.getUniqueAlbumIdentifier(res)
}
await utils.db.table('albums')
.where(filter)
.update(update)
utils.deleteStoredAlbumRenders([id])
utils.invalidateStatsCache('albums')
if (req.body.requestLink) {
// Rename album ZIP if it exists
const zipFullPath = path.join(paths.zips, `${album.identifier}.zip`)
if (await jetpack.existsAsync(zipFullPath) === 'file') {
await jetpack.renameAsync(zipFullPath, `${update.identifier}.zip`)
}
return res.json({
success: true,
identifier: update.identifier
})
} else {
return res.json({ success: true, name })
}
}
self.rename = async (req, res) => {
// Re-map Request.body for .edit()
req.body = {
_legacy: true,
name: req.body.name
}
return self.edit(req, res)
}
self.get = async (req, res) => {
const identifier = req.path_parameters && req.path_parameters.identifier
if (identifier === undefined) {
throw new ClientError('No identifier provided.')
}
const album = await utils.db.table('albums')
.where({
identifier,
enabled: 1
})
.first()
if (!album || album.public === 0) {
throw new ClientError('Album not found.', { statusCode: 404 })
}
const title = album.name
const files = await utils.db.table('files')
.select('name')
.where('albumid', album.id)
.orderBy('id', 'desc')
for (const file of files) {
if (req.locals.upstreamCompat) {
file.url = `${config.domain}/${file.name}`
} else {
file.file = `${config.domain}/${file.name}`
}
const extname = utils.extname(file.name)
if (utils.mayGenerateThumb(extname)) {
let thumbext = '.png'
if (utils.isAnimatedThumb(extname)) thumbext = '.gif'
file.thumb = `${config.domain}/thumbs/${file.name.slice(0, -extname.length)}${thumbext}`
/* // TODO: Upstream's API response is no longer identical to this.
if (req.locals.upstreamCompat) {
file.thumbSquare = file.thumb
}
*/
}
}
return res.json({
success: true,
description: 'Successfully retrieved files.',
title,
download: Boolean(album.download),
count: files.length,
files
})
}
self.getUpstreamCompat = async (req, res) => {
// If requested via /api/album/:identifier,
// map to .get() with chibisafe/upstream compatibility
// This API is known to be used in Pitu/Magane
// TODO: Upstream's API response is no longer identical to this, please fix.
req.locals.upstreamCompat = true
res._json = res.json
res.json = (body = {}) => {
// Rebuild JSON payload to match lolisafe upstream
const rebuild = {}
const maps = {
success: null,
description: 'message',
title: 'name',
download: 'downloadEnabled',
count: null
}
Object.keys(body).forEach(key => {
if (maps[key] !== undefined) {
if (maps[key]) rebuild[maps[key]] = body[key]
} else {
rebuild[key] = body[key]
}
})
if (rebuild.message) {
rebuild.message = rebuild.message.replace(/\.$/, '')
}
return res._json(rebuild)
}
return self.get(req, res)
}
self.generateZip = async (req, res) => {
const versionString = parseInt(req.query_parameters.v)
const identifier = req.path_parameters && req.path_parameters.identifier
if (identifier === undefined) {
throw new ClientError('No identifier provided.')
}
if (!config.uploads.generateZips) {
throw new ClientError('ZIP generation disabled.', { statusCode: 403 })
}
const album = await utils.db.table('albums')
.where({
identifier,
enabled: 1
})
.first()
if (!album) {
throw new ClientError('Album not found.', { statusCode: 404 })
} else if (album.download === 0) {
throw new ClientError('Download for this album is disabled.', { statusCode: 403 })
}
if ((isNaN(versionString) || versionString <= 0) && album.editedAt) {
return res.redirect(`${album.identifier}?v=${album.editedAt}`)
}
// Downloading existing album ZIP archive if still valid
if (album.zipGeneratedAt > album.editedAt) {
try {
const filePath = path.join(paths.zips, `${identifier}.zip`)
const stat = await fsPromises.stat(filePath)
return serveAlbumZipInstance.handle(req, res, filePath, stat, (req, res) => {
res.header('Content-Disposition', contentDisposition(`${album.name}.zip`, { type: 'inline' }))
})
} catch (error) {
// Re-throw non-ENOENT error
if (error.code !== 'ENOENT') {
throw error
}
}
}
// If EventEmitter already exists for this album ZIP generation, wait for it
if (self.zipEmitters.has(identifier)) {
return new Promise((resolve, reject) => {
logger.log(`Waiting previous zip task for album: ${identifier}.`)
self.zipEmitters.get(identifier).once('done', (result, clientErr) => {
if (clientErr || !result) {
return reject(clientErr || new ServerError())
}
return resolve(result)
})
}).then(async result =>
serveAlbumZipInstance.handle(req, res, result.path, result.stat, (req, res) => {
res.header('Content-Disposition', contentDisposition(result.name, { type: 'inline' }))
})
)
}
// Create EventEmitter for this album ZIP generation
self.zipEmitters.set(identifier, new ZipEmitter(identifier))
logger.log(`Starting zip task for album: ${identifier}.`)
const files = await utils.db.table('files')
.select('name', 'size', 'timestamp')
.where('albumid', album.id)
if (files.length === 0) {
logger.log(`Finished zip task for album: ${identifier} (no files).`)
// Remove album ZIP if it exists
await jetpack.removeAsync(path.join(paths.zips, `${identifier}.zip`))
const clientErr = new ClientError('There are no files in the album.', { statusCode: 200 })
self.zipEmitters.get(identifier).emit('done', null, null, clientErr)
throw clientErr
}
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 clientErr = new ClientError(`Total size of all files in the album exceeds ${zipMaxTotalSize} MB limit.`, { statusCode: 403 })
self.zipEmitters.get(identifier).emit('done', null, null, clientErr)
throw clientErr
}
}
const zipPath = path.join(paths.zips, `${album.identifier}.zip`)
const archive = new Zip()
try {
for (const file of files) {
const fullPath = path.join(paths.uploads, file.name)
archive.file(file.name, jetpack.createReadStream(fullPath), {
// Use file's upload timestamp as file's modified time in the ZIP archive.
// Timezone information does not seem to persist,
// so the displayed modified time will likely always be in UTC+0.
date: new Date(file.timestamp * 1000)
})
}
await new Promise((resolve, reject) => {
archive.generateNodeStream(zipOptions)
.pipe(jetpack.createWriteStream(zipPath))
.on('error', error => reject(error))
.on('finish', () => resolve())
})
} catch (error) {
logger.error(error)
throw new ServerError(error.message)
}
logger.log(`Finished zip task for album: ${identifier} (success).`)
await utils.db.table('albums')
.where('id', album.id)
.update('zipGeneratedAt', Math.floor(Date.now() / 1000))
utils.invalidateStatsCache('albums')
const result = {
path: path.join(paths.zips, `${identifier}.zip`),
name: `${album.name}.zip`
}
result.stat = await fsPromises.stat(result.path)
// Notify all other awaiting Requests, if any
self.zipEmitters.get(identifier).emit('done', result)
// Conclude this Request by streaming the album ZIP archive
return serveAlbumZipInstance.handle(req, res, result.path, result.stat, (req, res) => {
res.header('Content-Disposition', contentDisposition(result.name, { type: 'inline' }))
})
}
self.addFiles = async (req, res) => {
const ids = req.body.ids
if (!Array.isArray(ids) || !ids.length) {
throw new ClientError('No files specified.')
}
const issuperadmin = perms.is(req.locals.user, 'superadmin')
let albumid = parseInt(req.body.albumid)
if (isNaN(albumid) || albumid < 0) {
albumid = null
}
const failed = []
const albumids = []
// Wrap within a Promise then-async block for custom error handling
return Promise.resolve().then(async () => {
if (albumid !== null) {
const album = await utils.db.table('albums')
.where('id', albumid)
.where(function () {
// Only allow superadmins to arbitrarily add/remove files to/from any albums
// NOTE: Dashboard does not facilitate this, intended for manual API calls
if (!issuperadmin) {
this.where('userid', req.locals.user.id)
}
})
.first()
if (!album) {
throw new ClientError('Album does not exist or it does not belong to the user.', { statusCode: 404 })
}
// Insert this album's ID into "albumids" array to be updated later
albumids.push(albumid)
}
// Query all owned files matching submitted IDs
const files = await utils.db.table('files')
.whereIn('id', ids)
.where('userid', req.locals.user.id)
// Push IDs not found in database into "failed" array
failed.push(...ids.filter(id => !files.find(file => file.id === id)))
await utils.db.transaction(async trx => {
// Update files' associated album IDs
await trx('files')
.whereIn('id', files.map(file => file.id))
.update('albumid', albumid)
utils.invalidateStatsCache('albums')
// Insert all previous albums' IDs into "albumids" array to be updated later
files.forEach(file => {
if (file.albumid && !albumids.includes(file.albumid)) {
albumids.push(file.albumid)
}
})
// Update all relevant albums' "editedAt" timestamp
await trx('albums')
.whereIn('id', albumids)
.update('editedAt', Math.floor(Date.now() / 1000))
utils.deleteStoredAlbumRenders(albumids)
})
return res.json({ success: true, failed })
}).catch(error => {
if (Array.isArray(failed) && (failed.length === ids.length)) {
throw new ServerError(`Could not ${albumid === null ? 'add' : 'remove'} any files ${albumid === null ? 'to' : 'from'} the album.`)
}
throw error
})
}
module.exports = self