filesafe/controllers/uploadController.js
Bobby Wibowo 3ca692d8c7
Updates
* Refactored all instances of "failedIds" and "albumIds" to "failedids" and "albumids" respectively. Abandoning camel case for these ones.

* Refactored the way it looks into which albums the files are supposed to be added into.
For /api/upload/finishchunks, you can add "albumid" to each object in files[] to specify which album you want the finsihed chunks to be added into. Each object may have different album IDs.
For /api/upload, which is regular uploads, unfortunately you can only choose one album at a time (by adding "albumid" to the request headers, like usual). It uses the same function as the one used for finishchunks to add the files into album, so it shouldn't be hard to extend this ability to regular uploads, someday in the future.

* Fixed a bug in /api/upload/finishchunks. Previously you couldn't ever get it to work.

* Updated error message when successful uploads could not be added to album.

* "albumid" will no longer be added to request headers if they are chunked uploads. They'd have been ignored anyways.
2018-04-05 19:54:24 +07:00

583 lines
18 KiB
JavaScript

const config = require('../config.js')
const path = require('path')
const multer = require('multer')
const randomstring = require('randomstring')
const db = require('knex')(config.database)
const crypto = require('crypto')
const fs = require('fs')
const rimraf = require('rimraf')
const utils = require('./utilsController.js')
const uploadsController = {}
// Let's default it to only 1 try (for missing config key)
const maxTries = config.uploads.maxTries || 1
const uploadDir = path.join(__dirname, '..', config.uploads.folder)
const chunkedUploads = config.uploads.chunkedUploads && config.uploads.chunkedUploads.enabled
const chunksDir = path.join(uploadDir, 'chunks')
const maxSizeBytes = parseInt(config.uploads.maxSize) * 1000000
const storage = multer.diskStorage({
destination (req, file, cb) {
// If chunked uploads is disabled or the uploaded file is not a chunk
if (!chunkedUploads || (req.body.uuid === undefined && req.body.chunkindex === undefined)) {
return cb(null, uploadDir)
}
// Check for the existence of UUID dir in chunks dir
const uuidDir = path.join(chunksDir, req.body.uuid)
fs.access(uuidDir, error => {
// If it exists, callback
if (!error) { return cb(null, uuidDir) }
// It it doesn't, then make it first
fs.mkdir(uuidDir, error => {
// If there was no error, callback
if (!error) { return cb(null, uuidDir) }
// Otherwise, log it
console.log(error)
// eslint-disable-next-line standard/no-callback-literal
return cb('Could not process the chunked upload. Try again?')
})
})
},
filename (req, file, cb) {
const extension = path.extname(file.originalname)
// If chunked uploads is disabled or the uploaded file is not a chunk
if (!chunkedUploads || (req.body.uuid === undefined && req.body.chunkindex === undefined)) {
const length = uploadsController.getFileNameLength(req)
return uploadsController.getUniqueRandomName(length, extension, cb)
}
// index.extension (e.i. 0.jpg, 1.jpg, ..., n.jpg - will prepend zeros depending on the amount of chunks)
const digits = req.body.totalchunkcount !== undefined ? String(req.body.totalchunkcount - 1).length : 1
const zeros = new Array(digits + 1).join('0')
const name = (zeros + req.body.chunkindex).slice(-digits)
return cb(null, name + extension)
}
})
const upload = multer({
storage,
limits: {
fileSize: config.uploads.maxSize
},
fileFilter (req, file, cb) {
// If there are no blocked extensions
if (config.blockedExtensions === undefined) {
return cb(null, true)
}
// If the extension is blocked
if (config.blockedExtensions.some(extension => {
return path.extname(file.originalname).toLowerCase() === extension.toLowerCase()
})) {
// eslint-disable-next-line standard/no-callback-literal
return cb('This file extension is not allowed.')
}
if (chunkedUploads) {
// Re-map Dropzone keys so people can manually use the API without prepending 'dz'
const keys = Object.keys(req.body)
if (keys.length) {
for (const key of keys) {
if (!/^dz/.test(key)) { continue }
req.body[key.replace(/^dz/, '')] = req.body[key]
delete req.body[key]
}
}
const totalFileSize = parseInt(req.body.totalfilesize)
if (!isNaN(totalFileSize) && totalFileSize > maxSizeBytes) {
// eslint-disable-next-line standard/no-callback-literal
return cb('Chunked upload error. Total file size is larger than maximum file size.')
}
}
// If the extension is not blocked
return cb(null, true)
}
}).array('files[]')
uploadsController.getFileNameLength = req => {
// If the user has a preferred file length, make sure it is within the allowed range
if (req.headers.filelength) {
return Math.min(Math.max(req.headers.filelength, config.uploads.fileLength.min), config.uploads.fileLength.max)
}
// Let's default it to 32 characters when config key is falsy
return config.uploads.fileLength.default || 32
}
uploadsController.getUniqueRandomName = (length, extension, cb) => {
const access = i => {
const name = randomstring.generate(length) + extension
fs.access(path.join(uploadDir, name), error => {
// If a file with the same name does not exist
if (error) { return cb(null, name) }
// If a file with the same name already exists, log to console
console.log(`A file named ${name} already exists (${++i}/${maxTries}).`)
// If it still haven't reached allowed maximum tries, then try again
if (i < maxTries) { return access(i) }
// eslint-disable-next-line standard/no-callback-literal
return cb('Could not allocate a unique random name. Try again?')
})
}
// Get us a unique random name
access(0)
}
uploadsController.upload = async (req, res, next) => {
let user
if (config.private === true) {
user = await utils.authorize(req, res)
if (!user) { return }
} else if (req.headers.token) {
user = await db.table('users').where('token', req.headers.token).first()
}
if (user && (user.enabled === false || user.enabled === 0)) {
return res.json({
success: false,
description: 'This account has been disabled.'
})
}
if (user && user.fileLength && !req.headers.filelength) {
req.headers.filelength = user.fileLength
}
let albumid = parseInt(req.headers.albumid || req.params.albumid)
if (isNaN(albumid)) { albumid = null }
return uploadsController.actuallyUpload(req, res, user, albumid)
}
uploadsController.actuallyUpload = async (req, res, user, albumid) => {
const erred = error => {
console.log(error)
res.json({
success: false,
description: error.toString()
})
}
upload(req, res, async error => {
if (error) { return erred(error) }
if (req.files.length === 0) { return erred(new Error('No files.')) }
// If chunked uploads is enabeld and the uploaded file is a chunk, then just say that it was a success
if (chunkedUploads && req.body.uuid) { return res.json({ success: true }) }
const infoMap = req.files.map(file => {
if (albumid) { file.albumid = albumid }
return {
path: path.join(__dirname, '..', config.uploads.folder, file.filename),
data: file
}
})
const result = await uploadsController.writeFilesToDb(req, res, user, infoMap)
.catch(erred)
if (result) {
return uploadsController.processFilesForDisplay(req, res, result.files, result.existingFiles)
}
})
}
uploadsController.finishChunks = async (req, res, next) => {
if (!config.uploads.chunkedUploads || !config.uploads.chunkedUploads.enabled) {
return res.json({
success: false,
description: 'Chunked uploads is disabled at the moment.'
})
}
let user
if (config.private === true) {
user = await utils.authorize(req, res)
if (!user) { return }
} else if (req.headers.token) {
user = await db.table('users').where('token', req.headers.token).first()
}
if (user && (user.enabled === false || user.enabled === 0)) {
return res.json({
success: false,
description: 'This account has been disabled.'
})
}
if (user && user.fileLength && !req.headers.filelength) {
req.headers.filelength = user.fileLength
}
let albumid = parseInt(req.headers.albumid || req.params.albumid)
if (isNaN(albumid)) { albumid = null }
return uploadsController.actuallyFinishChunks(req, res, user, albumid)
}
uploadsController.actuallyFinishChunks = async (req, res, user, albumid) => {
const erred = error => {
console.log(error)
res.json({
success: false,
description: error.toString()
})
}
const files = req.body.files
if (!files) { return erred(new Error('Missing files array.')) }
let iteration = 0
const infoMap = []
for (const file of files) {
const { uuid, count } = file
if (!uuid || !count) { return erred(new Error('Missing UUID and/or chunks count.')) }
const chunksDirUuid = path.join(chunksDir, uuid)
fs.readdir(chunksDirUuid, async (error, chunks) => {
if (error) { return erred(error) }
if (count < chunks.length) { return erred(new Error('Chunks count mismatch.')) }
const extension = path.extname(chunks[0])
const length = uploadsController.getFileNameLength(req)
uploadsController.getUniqueRandomName(length, extension, async (error, name) => {
if (error) { return erred(error) }
const destination = path.join(uploadDir, name)
const destFileStream = fs.createWriteStream(destination, { flags: 'a' })
chunks.sort()
const appended = await uploadsController.appendToStream(destFileStream, chunksDirUuid, chunks)
.catch(erred)
rimraf(chunksDirUuid, error => {
if (error) {
console.log(error)
}
})
if (!appended) { return }
const data = {
filename: name,
originalname: file.original || '',
mimetype: file.type || '',
size: file.size || 0
}
data.albumid = parseInt(file.albumid)
if (isNaN(data.albumid)) { data.albumid = albumid }
infoMap.push({
path: destination,
data
})
iteration++
if (iteration >= files.length) {
const result = await uploadsController.writeFilesToDb(req, res, user, infoMap)
.catch(erred)
if (result) {
return uploadsController.processFilesForDisplay(req, res, result.files, result.existingFiles)
}
}
})
})
}
}
uploadsController.appendToStream = async (destFileStream, chunksDirUuid, chunks) => {
return new Promise((resolve, reject) => {
const append = i => {
if (i < chunks.length) {
fs.createReadStream(path.join(chunksDirUuid, chunks[i]))
.on('end', () => {
append(i + 1)
})
.on('error', error => {
console.log(error)
destFileStream.end()
return reject(error)
})
.pipe(destFileStream, { end: false })
} else {
destFileStream.end()
return resolve(true)
}
}
append(0)
})
}
uploadsController.writeFilesToDb = async (req, res, user, infoMap) => {
const albumsAuthorized = {}
return new Promise((resolve, reject) => {
let iteration = 0
const files = []
const existingFiles = []
for (const info of infoMap) {
// Check if the file exists by checking hash and size
const hash = crypto.createHash('md5')
const stream = fs.createReadStream(info.path)
stream.on('data', data => {
hash.update(data, 'utf8')
})
stream.on('end', async () => {
const fileHash = hash.digest('hex')
const dbFile = await db.table('files')
.where(function () {
if (user === undefined) {
this.whereNull('userid')
} else {
this.where('userid', user.id)
}
})
.where({
hash: fileHash,
size: info.data.size
})
.first()
if (!dbFile) {
if (info.data.albumid && albumsAuthorized[info.data.albumid] === undefined) {
const authorized = await db.table('albums')
.where({
id: info.data.albumid,
userid: user.id
})
.first()
albumsAuthorized[info.data.albumid] = Boolean(authorized)
}
files.push({
name: info.data.filename,
original: info.data.originalname,
type: info.data.mimetype,
size: info.data.size,
hash: fileHash,
ip: req.ip,
albumid: albumsAuthorized[info.data.albumid] ? info.data.albumid : null,
userid: user !== undefined ? user.id : null,
timestamp: Math.floor(Date.now() / 1000)
})
} else {
utils.deleteFile(info.data.filename).then(() => {}).catch(error => console.log(error))
existingFiles.push(dbFile)
}
iteration++
if (iteration >= infoMap.length) {
return resolve({ files, existingFiles })
}
})
}
})
}
uploadsController.processFilesForDisplay = async (req, res, files, existingFiles) => {
const basedomain = config.domain
if (files.length === 0) {
return res.json({
success: true,
files: existingFiles.map(file => {
return {
name: file.name,
size: file.size,
url: `${basedomain}/${file.name}`
}
})
})
}
// Insert new files to DB
await db.table('files').insert(files)
// Push existing files to array for response
for (const efile of existingFiles) {
files.push(efile)
}
const albumids = []
for (const file of files) {
const ext = path.extname(file.name).toLowerCase()
if ((config.uploads.generateThumbnails.image && utils.imageExtensions.includes(ext)) || (config.uploads.generateThumbnails.video && utils.videoExtensions.includes(ext))) {
file.thumb = `${basedomain}/thumbs/${file.name.slice(0, -ext.length)}.png`
utils.generateThumbs(file)
}
if (file.albumid && !albumids.includes(file.albumid)) {
albumids.push(file.albumid)
}
}
let albumSuccess = true
if (albumids.length) {
const editedAt = Math.floor(Date.now() / 1000)
await Promise.all(albumids.map(albumid => {
return db.table('albums')
.where('id', albumid)
.update('editedAt', editedAt)
.then(() => {})
.catch(error => {
console.log(error)
albumSuccess = false
})
}))
}
return res.json({
success: albumSuccess,
description: albumSuccess ? null : 'Warning: Some files may have failed to be added to album.',
files: files.map(file => {
return {
name: file.name,
size: file.size,
url: `${basedomain}/${file.name}`
}
})
})
}
uploadsController.delete = async (req, res) => {
const user = await utils.authorize(req, res)
if (!user) { return }
const id = req.body.id
if (id === undefined || id === '') {
return res.json({ success: false, description: 'No file specified.' })
}
const file = await db.table('files')
.where('id', id)
.where(function () {
if (user.username !== 'root') {
this.where('userid', user.id)
}
})
.first()
try {
await utils.deleteFile(file.name).catch(error => {
// ENOENT is missing file, for whatever reason, then just delete from db anyways
if (error.code !== 'ENOENT') { throw error }
})
await db.table('files')
.where('id', id)
.del()
if (file.albumid) {
await db.table('albums')
.where('id', file.albumid)
.update('editedAt', Math.floor(Date.now() / 1000))
}
} catch (error) {
console.log(error)
}
return res.json({ success: true })
}
uploadsController.bulkDelete = async (req, res) => {
const user = await utils.authorize(req, res)
if (!user) { return }
const ids = req.body.ids
if (ids === undefined || !ids.length) {
return res.json({ success: false, description: 'No files specified.' })
}
const failedids = await utils.bulkDeleteFilesByIds(ids, user)
if (failedids.length < ids.length) {
return res.json({
success: true,
failedids
})
}
return res.json({
success: false,
description: 'Could not delete any of the selected files.'
})
}
uploadsController.list = async (req, res) => {
const user = await utils.authorize(req, res)
if (!user) { return }
let offset = req.params.page
if (offset === undefined) { offset = 0 }
const files = await db.table('files')
.where(function () {
if (req.params.id === undefined) {
this.where('id', '<>', '')
} else {
this.where('albumid', req.params.id)
}
})
.where(function () {
if (user.username !== 'root') { this.where('userid', user.id) }
})
.orderBy('id', 'DESC')
.limit(25)
.offset(25 * offset)
.select('id', 'albumid', 'timestamp', 'name', 'userid', 'size')
const albums = await db.table('albums')
const basedomain = config.domain
const userids = []
for (const file of files) {
file.file = `${basedomain}/${file.name}`
file.date = new Date(file.timestamp * 1000)
file.date = utils.getPrettyDate(file.date)
file.size = utils.getPrettyBytes(parseInt(file.size))
file.album = ''
if (file.albumid !== undefined) {
for (const album of albums) {
if (file.albumid === album.id) {
file.album = album.name
}
}
}
// Only push usernames if we are root
if (user.username === 'root') {
if (file.userid !== undefined && file.userid !== null && file.userid !== '') {
userids.push(file.userid)
}
}
const ext = path.extname(file.name).toLowerCase()
if ((config.uploads.generateThumbnails.image && utils.imageExtensions.includes(ext)) || (config.uploads.generateThumbnails.video && utils.videoExtensions.includes(ext))) {
file.thumb = `${basedomain}/thumbs/${file.name.slice(0, -ext.length)}.png`
}
}
// If we are a normal user, send response
if (user.username !== 'root') { return res.json({ success: true, files }) }
// If we are root but there are no uploads attached to a user, send response
if (userids.length === 0) { return res.json({ success: true, files }) }
const users = await db.table('users').whereIn('id', userids)
for (const dbUser of users) {
for (const file of files) {
if (file.userid === dbUser.id) {
file.username = dbUser.username
}
}
}
return res.json({ success: true, files })
}
module.exports = uploadsController