Browse Source

Merge remote-tracking branch 'upstream/safe.fiery.me'

Note: sqlite3 JS dep version is currently fixed as there was no binary available during build
master
Kody 4 months ago
parent
commit
820363c473
  1. 4
      .eslintrc.js
  2. 4
      .github/workflows/build.yml
  3. 11
      config.sample.js
  4. 283
      controllers/albumsController.js
  5. 255
      controllers/authController.js
  6. 33
      controllers/handlers/apiErrorsHandler.js
  7. 6
      controllers/permissionController.js
  8. 62
      controllers/tokenController.js
  9. 997
      controllers/uploadController.js
  10. 15
      controllers/utils/ClientError.js
  11. 18
      controllers/utils/ServerError.js
  12. 0
      controllers/utils/multerStorage.js
  13. 638
      controllers/utilsController.js
  14. 4
      dist/css/style.css
  15. 2
      dist/css/style.css.map
  16. 2
      dist/js/dashboard.js
  17. 2
      dist/js/dashboard.js.map
  18. 2
      dist/js/home.js
  19. 2
      dist/js/home.js.map
  20. 2
      dist/js/misc/newsfeed.js
  21. 2
      dist/js/misc/newsfeed.js.map
  22. 2
      dist/js/misc/render.js.map
  23. 15
      lolisafe.js
  24. 23
      package.json
  25. 3
      src/css/style.scss
  26. 139
      src/js/dashboard.js
  27. 48
      src/js/home.js
  28. 37
      src/js/misc/newsfeed.js
  29. 2
      src/js/misc/render.js
  30. 2
      src/versions.json
  31. 2
      views/home.njk
  32. 915
      yarn.lock

4
.eslintrc.js

@ -10,11 +10,9 @@ module.exports = {
'standard'
],
rules: {
'no-throw-literal': 0,
'object-shorthand': [
'error',
'always'
],
'node/no-callback-literal': 0
]
}
}

4
.github/workflows/build.yml

@ -12,7 +12,7 @@ on:
jobs:
build:
name: Build client assets and bump v1 version string
name: Rebuild client assets and bump v1 version string
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
@ -42,7 +42,7 @@ jobs:
- uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: 'AUTO: Rebuilt client assets and bumped v1 version string'
commit_message: 'dist: rebuilt client assets and bumped v1 version string'
commit_user_name: loli-bot
commit_user_email: hi@fiery.me
env:

11
config.sample.js

@ -474,11 +474,11 @@ module.exports = {
/*
Thumbnails are only used in the dashboard and album's public pages.
You need to install a separate binary called ffmpeg (https://ffmpeg.org/) for video thumbnails.
NOTE: Placeholder defaults to 'public/images/unavailable.png'.
*/
generateThumbs: {
image: true,
video: false,
// Placeholder defaults to 'public/images/unavailable.png'.
placeholder: null,
size: 200
},
@ -503,7 +503,14 @@ module.exports = {
stripTags: {
default: false,
video: false,
force: false
force: false,
// Supporting the extensions below requires using custom globally-installed libvips.
// https://sharp.pixelplumbing.com/install#custom-libvips
blacklistExtensions: [
// GIFs require libvips compiled with ImageMagick/GraphicsMagick support.
// https://sharp.pixelplumbing.com/api-output#gif
'.gif'
]
},
/*

283
controllers/albumsController.js

@ -7,6 +7,9 @@ const paths = require('./pathsController')
const perms = require('./permissionController')
const uploadController = require('./uploadController')
const utils = require('./utilsController')
const apiErrorsHandler = require('./handlers/apiErrorsHandler.js')
const ClientError = require('./utils/ClientError')
const ServerError = require('./utils/ServerError')
const config = require('./../config')
const logger = require('./../logger')
const db = require('knex')(config.database)
@ -66,28 +69,27 @@ self.getUniqueRandomName = async () => {
return identifier
}
throw 'Sorry, we could not allocate a unique random identifier. Try again?'
throw new ServerError('Failed to allocate a unique identifier for the album. Try again?')
}
self.list = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const all = req.headers.all === '1'
const simple = req.headers.simple
const ismoderator = perms.is(user, 'moderator')
if (all && !ismoderator) return res.status(403).end()
const all = req.headers.all === '1'
const simple = req.headers.simple
const ismoderator = perms.is(user, 'moderator')
if (all && !ismoderator) return res.status(403).end()
const filter = function () {
if (!all) {
this.where({
enabled: 1,
userid: user.id
})
const filter = function () {
if (!all) {
this.where({
enabled: 1,
userid: user.id
})
}
}
}
try {
// Query albums count for pagination
const count = await db.table('albums')
.where(filter)
@ -162,24 +164,22 @@ self.list = async (req, res, next) => {
users[user.id] = user.username
}
return res.json({ success: true, albums, count, users, homeDomain })
await res.json({ success: true, albums, count, users, homeDomain })
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.create = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const name = typeof req.body.name === 'string'
? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
: ''
const name = typeof req.body.name === 'string'
? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
: ''
if (!name) return res.json({ success: false, description: 'No album name specified.' })
if (!name) throw new ClientError('No album name specified.')
try {
const album = await db.table('albums')
.where({
name,
@ -188,7 +188,7 @@ self.create = async (req, res, next) => {
})
.first()
if (album) return res.json({ success: false, description: 'There is already an album with that name.' })
if (album) throw new ClientError('Album name already in use.', { statusCode: 403 })
const identifier = await self.getUniqueRandomName()
@ -209,10 +209,9 @@ self.create = async (req, res, next) => {
utils.invalidateStatsCache('albums')
self.onHold.delete(identifier)
return res.json({ success: true, id: ids[0] })
await 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?' })
return apiErrorsHandler(error, req, res, next)
}
}
@ -222,14 +221,13 @@ self.delete = async (req, res, next) => {
}
self.disable = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const id = req.body.id
const purge = req.body.purge
if (!Number.isFinite(id)) return res.json({ success: false, description: 'No album specified.' })
const id = req.body.id
const purge = req.body.purge
if (!Number.isFinite(id)) throw new ClientError('No album specified.')
try {
if (purge) {
const files = await db.table('files')
.where({
@ -263,55 +261,72 @@ self.disable = async (req, res, next) => {
.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?' })
try {
await paths.unlink(path.join(paths.zips, `${identifier}.zip`))
} catch (error) {
// Re-throw non-ENOENT error
if (error.code !== 'ENOENT') throw error
}
await res.json({ success: true })
} catch (error) {
return apiErrorsHandler(error, req, res, next)
}
return res.json({ success: true })
}
self.edit = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const ismoderator = perms.is(user, 'moderator')
const ismoderator = perms.is(user, 'moderator')
const id = parseInt(req.body.id)
if (isNaN(id)) return res.json({ success: false, description: 'No album specified.' })
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))
: ''
const name = typeof req.body.name === 'string'
? utils.escape(req.body.name.trim().substring(0, self.titleMaxLength))
: ''
if (!name) return res.json({ success: false, description: 'No name specified.' })
if (!name) throw new ClientError('No album name specified.')
const filter = function () {
this.where('id', id)
const filter = function () {
this.where('id', id)
if (!ismoderator) {
this.andWhere({
enabled: 1,
userid: user.id
})
if (!ismoderator) {
this.andWhere({
enabled: 1,
userid: user.id
})
}
}
}
try {
const album = await db.table('albums')
.where(filter)
.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.' })
throw new ClientError('Could not get album with the specified ID.')
}
const albumNewState = (ismoderator && typeof req.body.enabled !== 'undefined')
? Boolean(req.body.enabled)
: null
const nameInUse = await db.table('albums')
.where({
name,
enabled: 1,
userid: user.id
})
.whereNot('id', id)
.first()
if ((album.enabled || (albumNewState === true)) && nameInUse) {
if (req._old) {
// Old 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 = {
@ -323,8 +338,8 @@ self.edit = async (req, res, next) => {
: ''
}
if (ismoderator && typeof req.body.enabled !== 'undefined') {
update.enabled = Boolean(req.body.enabled)
if (albumNewState !== null) {
update.enabled = albumNewState
}
if (req.body.requestLink) {
@ -346,20 +361,19 @@ self.edit = async (req, res, next) => {
const newZip = path.join(paths.zips, `${update.identifier}.zip`)
await paths.rename(oldZip, newZip)
} catch (error) {
// Re-throw error
// Re-throw non-ENOENT error
if (error.code !== 'ENOENT') throw error
}
return res.json({
await res.json({
success: true,
identifier: update.identifier
})
} else {
return res.json({ success: true, name })
await res.json({ success: true, name })
}
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
@ -370,12 +384,12 @@ self.rename = async (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 identifier = req.params.identifier
if (identifier === undefined) {
throw new ClientError('No identifier provided.')
}
const album = await db.table('albums')
.where({
identifier,
@ -384,7 +398,7 @@ self.get = async (req, res, next) => {
.first()
if (!album || album.public === 0) {
return res.status(404).json({ success: false, description: 'The album could not be found.' })
throw new ClientError('Album not found.', { statusCode: 404 })
}
const title = album.name
@ -407,7 +421,7 @@ self.get = async (req, res, next) => {
}
}
return res.json({
await res.json({
success: true,
description: 'Successfully retrieved files.',
title,
@ -416,30 +430,23 @@ self.get = async (req, res, next) => {
files
})
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occcured. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.generateZip = async (req, res, next) => {
const versionString = parseInt(req.query.v)
try {
const versionString = parseInt(req.query.v)
const identifier = req.params.identifier
if (identifier === undefined) {
return res.status(401).json({
success: false,
description: 'No identifier provided.'
})
}
const identifier = req.params.identifier
if (identifier === undefined) {
throw new ClientError('No identifier provided.')
}
if (!config.uploads.generateZips) {
return res.status(401).json({
success: false,
description: 'ZIP generation disabled.'
})
}
if (!config.uploads.generateZips) {
throw new ClientError('ZIP generation disabled.', { statusCode: 403 })
}
try {
const album = await db.table('albums')
.where({
identifier,
@ -448,9 +455,9 @@ self.generateZip = async (req, res, next) => {
.first()
if (!album) {
return res.json({ success: false, description: 'Album not found.' })
throw new ClientError('Album not found.', { statusCode: 404 })
} else if (album.download === 0) {
return res.json({ success: false, description: 'Download for this album is disabled.' })
throw new ClientError('Download for this album is disabled.', { statusCode: 403 })
}
if ((isNaN(versionString) || versionString <= 0) && album.editedAt) {
@ -461,20 +468,20 @@ self.generateZip = async (req, res, next) => {
try {
const filePath = path.join(paths.zips, `${identifier}.zip`)
await paths.access(filePath)
return res.download(filePath, `${album.name}.zip`)
await res.download(filePath, `${album.name}.zip`)
} catch (error) {
// Re-throw error
// Re-throw non-ENOENT error
if (error.code !== 'ENOENT') throw error
}
}
if (self.zipEmitters.has(identifier)) {
logger.log(`Waiting previous zip task for album: ${identifier}.`)
return self.zipEmitters.get(identifier).once('done', (filePath, fileName, json) => {
return self.zipEmitters.get(identifier).once('done', (filePath, fileName, clientErr) => {
if (filePath && fileName) {
res.download(filePath, fileName)
} else if (json) {
res.json(json)
} else if (clientErr) {
apiErrorsHandler(clientErr, req, res, next)
}
})
}
@ -488,24 +495,18 @@ self.generateZip = async (req, res, next) => {
.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)
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 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 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
}
}
@ -528,10 +529,7 @@ self.generateZip = async (req, res, next) => {
})
} catch (error) {
logger.error(error)
return res.status(500).json({
success: 'false',
description: error.toString()
})
throw new ServerError(error.message)
}
logger.log(`Finished zip task for album: ${identifier} (success).`)
@ -545,10 +543,9 @@ self.generateZip = async (req, res, next) => {
const fileName = `${album.name}.zip`
self.zipEmitters.get(identifier).emit('done', filePath, fileName)
return res.download(filePath, fileName)
await res.download(filePath, fileName)
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
@ -589,20 +586,20 @@ self.listFiles = async (req, res, next) => {
}
self.addFiles = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
let ids, albumid, failed, albumids
try {
const user = await utils.authorize(req)
const ids = req.body.ids
if (!Array.isArray(ids) || !ids.length) {
return res.json({ success: false, description: 'No files specified.' })
}
ids = req.body.ids
if (!Array.isArray(ids) || !ids.length) {
throw new ClientError('No files specified.')
}
let albumid = parseInt(req.body.albumid)
if (isNaN(albumid) || albumid < 0) albumid = null
albumid = parseInt(req.body.albumid)
if (isNaN(albumid) || albumid < 0) albumid = null
let failed = []
const albumids = []
try {
failed = []
albumids = []
if (albumid !== null) {
const album = await db.table('albums')
.where('id', albumid)
@ -614,10 +611,7 @@ self.addFiles = async (req, res, next) => {
.first()
if (!album) {
return res.json({
success: false,
description: 'Album does not exist or it does not belong to the user.'
})
throw new ClientError('Album does not exist or it does not belong to the user.', { statusCode: 404 })
}
albumids.push(albumid)
@ -632,6 +626,7 @@ self.addFiles = async (req, res, next) => {
await db.table('files')
.whereIn('id', files.map(file => file.id))
.update('albumid', albumid)
utils.invalidateStatsCache('albums')
files.forEach(file => {
if (file.albumid && !albumids.includes(file.albumid)) {
@ -644,16 +639,12 @@ self.addFiles = async (req, res, next) => {
.update('editedAt', Math.floor(Date.now() / 1000))
utils.invalidateAlbumsCache(albumids)
return res.json({ success: true, failed })
await 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.`
})
if (Array.isArray(failed) && (failed.length === ids.length)) {
return apiErrorsHandler(new ServerError(`Could not ${albumid === null ? 'add' : 'remove'} any files ${albumid === null ? 'to' : 'from'} the album.`), req, res, next)
} else {
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
}

255
controllers/authController.js

@ -5,8 +5,10 @@ const paths = require('./pathsController')
const perms = require('./permissionController')
const tokens = require('./tokenController')
const utils = require('./utilsController')
const apiErrorsHandler = require('./handlers/apiErrorsHandler.js')
const ClientError = require('./utils/ClientError')
const ServerError = require('./utils/ServerError')
const config = require('./../config')
const logger = require('./../logger')
const db = require('knex')(config.database)
// Don't forget to update min/max length of text inputs in auth.njk
@ -31,79 +33,69 @@ const self = {
const saltRounds = 10
self.verify = async (req, res, next) => {
const username = typeof req.body.username === 'string'
? req.body.username.trim()
: ''
if (!username) return res.json({ success: false, description: 'No username provided.' })
try {
const username = typeof req.body.username === 'string'
? req.body.username.trim()
: ''
if (!username) throw new ClientError('No username provided.')
const password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (!password) return res.json({ success: false, description: 'No password provided.' })
const password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (!password) throw new ClientError('No password provided.')
try {
const user = await db.table('users')
.where('username', username)
.first()
if (!user) return res.json({ success: false, description: 'Username does not exist.' })
if (!user) throw new ClientError('Username does not exist.')
if (user.enabled === false || user.enabled === 0) {
return res.json({ success: false, description: 'This account has been disabled.' })
throw new ClientError('This account has been disabled.', { statusCode: 403 })
}
const result = await bcrypt.compare(password, user.password)
if (result === false) {
return res.json({ success: false, description: 'Wrong password.' })
throw new ClientError('Wrong password.', { statusCode: 403 })
} else {
return res.json({ success: true, token: user.token })
await res.json({ success: true, token: user.token })
}
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.register = async (req, res, next) => {
if (config.enableUserAccounts === false) {
return res.json({ success: false, description: 'Registration is currently disabled.' })
}
try {
if (config.enableUserAccounts === false) {
throw new ClientError('Registration is currently disabled.', { statusCode: 403 })
}
const username = typeof req.body.username === 'string'
? req.body.username.trim()
: ''
if (username.length < self.user.min || username.length > self.user.max) {
return res.json({
success: false,
description: `Username must have ${self.user.min}-${self.user.max} characters.`
})
}
const username = typeof req.body.username === 'string'
? req.body.username.trim()
: ''
if (username.length < self.user.min || username.length > self.user.max) {
throw new ClientError(`Username must have ${self.user.min}-${self.user.max} characters.`)
}
const password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (password.length < self.pass.min || password.length > self.pass.max) {
return res.json({
success: false,
description: `Password must have ${self.pass.min}-${self.pass.max} characters.`
})
}
const password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (password.length < self.pass.min || password.length > self.pass.max) {
throw new ClientError(`Password must have ${self.pass.min}-${self.pass.max} characters.`)
}
try {
const user = await db.table('users')
.where('username', username)
.first()
if (user) return res.json({ success: false, description: 'Username already exists.' })
if (user) throw new ClientError('Username already exists.')
const hash = await bcrypt.hash(password, saltRounds)
const token = await tokens.generateUniqueToken()
if (!token) {
return res.json({
success: false,
description: 'Sorry, we could not allocate a unique token. Try again?'
})
throw new ServerError('Failed to allocate a unique token. Try again?')
}
await db.table('users')
@ -118,107 +110,91 @@ self.register = async (req, res, next) => {
utils.invalidateStatsCache('users')
tokens.onHold.delete(token)
return res.json({ success: true, token })
await res.json({ success: true, token })
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.changePassword = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
const password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (password.length < self.pass.min || password.length > self.pass.max) {
return res.json({
success: false,
description: `Password must have ${self.pass.min}-${self.pass.max} characters.`
})
}
try {
const user = await utils.authorize(req)
const password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (password.length < self.pass.min || password.length > self.pass.max) {
throw new ClientError(`Password must have ${self.pass.min}-${self.pass.max} characters.`)
}
const hash = await bcrypt.hash(password, saltRounds)
await db.table('users')
.where('id', user.id)
.update('password', hash)
return res.json({ success: true })
await res.json({ success: true })
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.assertPermission = (user, target) => {
if (!target) {
throw new Error('Could not get user with the specified ID.')
throw new ClientError('Could not get user with the specified ID.')
} else if (!perms.higher(user, target)) {
throw new Error('The user is in the same or higher group as you.')
throw new ClientError('The user is in the same or higher group as you.', { statusCode: 403 })
} else if (target.username === 'root') {
throw new Error('Root user may not be tampered with.')
throw new ClientError('Root user may not be tampered with.', { statusCode: 403 })
}
}
self.createUser = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
const isadmin = perms.is(user, 'admin')
if (!isadmin) return res.status(403).end()
const username = typeof req.body.username === 'string'
? req.body.username.trim()
: ''
if (username.length < self.user.min || username.length > self.user.max) {
return res.json({
success: false,
description: `Username must have ${self.user.min}-${self.user.max} characters.`
})
}
try {
const user = await utils.authorize(req)
let password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (password.length) {
if (password.length < self.pass.min || password.length > self.pass.max) {
return res.json({
success: false,
description: `Password must have ${self.pass.min}-${self.pass.max} characters.`
})
const isadmin = perms.is(user, 'admin')
if (!isadmin) return res.status(403).end()
const username = typeof req.body.username === 'string'
? req.body.username.trim()
: ''
if (username.length < self.user.min || username.length > self.user.max) {
throw new ClientError(`Username must have ${self.user.min}-${self.user.max} characters.`)
}
} else {
password = randomstring.generate(self.pass.rand)
}
let group = req.body.group
let permission
if (group !== undefined) {
permission = perms.permissions[group]
if (typeof permission !== 'number' || permission < 0) {
group = 'user'
permission = perms.permissions.user
let password = typeof req.body.password === 'string'
? req.body.password.trim()
: ''
if (password.length) {
if (password.length < self.pass.min || password.length > self.pass.max) {
throw new ClientError(`Password must have ${self.pass.min}-${self.pass.max} characters.`)
}
} else {
password = randomstring.generate(self.pass.rand)
}
}
try {
const user = await db.table('users')
let group = req.body.group
let permission
if (group !== undefined) {
permission = perms.permissions[group]
if (typeof permission !== 'number' || permission < 0) {
group = 'user'
permission = perms.permissions.user
}
}
const exists = await db.table('users')
.where('username', username)
.first()
if (user) return res.json({ success: false, description: 'Username already exists.' })
if (exists) throw new ClientError('Username already exists.')
const hash = await bcrypt.hash(password, saltRounds)
const token = await tokens.generateUniqueToken()
if (!token) {
return res.json({
success: false,
description: 'Sorry, we could not allocate a unique token. Try again?'
})
throw new ServerError('Failed to allocate a unique token. Try again?')
}
await db.table('users')
@ -233,24 +209,22 @@ self.createUser = async (req, res, next) => {
utils.invalidateStatsCache('users')
tokens.onHold.delete(token)
return res.json({ success: true, username, password, group })
await res.json({ success: true, username, password, group })
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.editUser = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const isadmin = perms.is(user, 'admin')
if (!isadmin) return res.status(403).end()
const isadmin = perms.is(user, 'admin')
if (!isadmin) throw new ClientError('', { statusCode: 403 })
const id = parseInt(req.body.id)
if (isNaN(id)) return res.json({ success: false, description: 'No user specified.' })
const id = parseInt(req.body.id)
if (isNaN(id)) throw new ClientError('No user specified.')
try {
const target = await db.table('users')
.where('id', id)
.first()
@ -261,7 +235,7 @@ self.editUser = async (req, res, next) => {
if (req.body.username !== undefined) {
update.username = String(req.body.username).trim()
if (update.username.length < self.user.min || update.username.length > self.user.max) {
throw new Error(`Username must have ${self.user.min}-${self.user.max} characters.`)
throw new ClientError(`Username must have ${self.user.min}-${self.user.max} characters.`)
}
}
@ -289,13 +263,9 @@ self.editUser = async (req, res, next) => {
const response = { success: true, update }
if (password) response.update.password = password
return res.json(response)
await res.json(response)
} catch (error) {
logger.error(error)
return res.status(500).json({
success: false,
description: error.message || 'An unexpected error occurred. Try again?'
})
return apiErrorsHandler(error, req, res, next)
}
}
@ -305,17 +275,16 @@ self.disableUser = async (req, res, next) => {
}
self.deleteUser = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const isadmin = perms.is(user, 'admin')
if (!isadmin) return res.status(403).end()
const isadmin = perms.is(user, 'admin')
if (!isadmin) throw new ClientError('', { statusCode: 403 })
const id = parseInt(req.body.id)
const purge = req.body.purge
if (isNaN(id)) return res.json({ success: false, description: 'No user specified.' })
const id = parseInt(req.body.id)
const purge = req.body.purge
if (isNaN(id)) throw new ClientError('No user specified.')
try {
const target = await db.table('users')
.where('id', id)
.first()
@ -358,6 +327,7 @@ self.deleteUser = async (req, res, next) => {
try {
await paths.unlink(path.join(paths.zips, `${album.identifier}.zip`))
} catch (error) {
// Re-throw non-ENOENT error
if (error.code !== 'ENOENT') throw error
}
}))
@ -368,12 +338,9 @@ self.deleteUser = async (req, res, next) => {
.del()
utils.invalidateStatsCache('users')
return res.json({ success: true })
await res.json({ success: true })
} catch (error) {
return res.status(500).json({
success: false,
description: error.message || 'An unexpected error occurred. Try again?'
})
return apiErrorsHandler(error, req, res, next)
}
}
@ -382,13 +349,12 @@ self.bulkDeleteUsers = async (req, res, next) => {
}
self.listUsers = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
try {
const user = await utils.authorize(req)
const isadmin = perms.is(user, 'admin')
if (!isadmin) return res.status(403).end()
const isadmin = perms.is(user, 'admin')
if (!isadmin) throw new ClientError('', { statusCode: 403 })
try {
const count = await db.table('users')
.count('id as count')
.then(rows => rows[0].count)
@ -421,10 +387,9 @@ self.listUsers = async (req, res, next) => {
pointers[upload.userid].usage += parseInt(upload.size)
}
return res.json({ success: true, users, count })
await res.json({ success: true, users, count })
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}

33
controllers/handlers/apiErrorsHandler.js

@ -0,0 +1,33 @@
const ClientError = require('./../utils/ClientError')
const ServerError = require('./../utils/ServerError')
const logger = require('./../../logger')
module.exports = (error, req, res, next) => {
if (!res) {
return logger.error(new Error('Missing "res" object.'))
}
// Error messages that can be returned to users
const isClientError = error instanceof ClientError
const isServerError = error instanceof ServerError
const logStack = (!isClientError && !isServerError) ||
(isServerError && error.logStack)
if (logStack) {
logger.error(error)
}
const statusCode = (isClientError || isServerError)
? error.statusCode
: 500
const description = (isClientError || isServerError)
? error.message
: 'An unexpected error occurred. Try again?'
if (description) {
return res.status(statusCode).json({ success: false, description })
} else {
return res.status(statusCode).end()
}
}

6
controllers/permissionController.js

@ -1,18 +1,18 @@
const self = {}
self.permissions = {
self.permissions = Object.freeze({
user: 0, // Upload & delete own files, create & delete albums
moderator: 50, // Delete other user's files
admin: 80, // Manage users (disable accounts) & create moderators
superadmin: 100 // Create admins
// Groups will inherit permissions from groups which have lower value
}
})
// returns true if user is in the group OR higher
self.is = (user, group) => {
// root bypass
if (user.username === 'root') return true
if (typeof group !== 'string' || !group) return false
const permission = user.permission || 0
return permission >= self.permissions[group]
}

62
controllers/tokenController.js

@ -1,8 +1,10 @@
const randomstring = require('randomstring')
const perms = require('./permissionController')
const utils = require('./utilsController')
const apiErrorsHandler = require('./handlers/apiErrorsHandler')
const ClientError = require('./utils/ClientError')
const ServerError = require('./utils/ServerError')
const config = require('./../config')
const logger = require('./../logger')
const db = require('knex')(config.database)
const self = {
@ -35,52 +37,54 @@ self.generateUniqueToken = async () => {
}
self.verify = async (req, res, next) => {
const token = typeof req.body.token === 'string'
? req.body.token.trim()
: ''
try {
const token = typeof req.body.token === 'string'
? req.body.token.trim()
: ''
if (!token) return res.json({ success: false, description: 'No token provided.' })
if (!token) throw new ClientError('No token provided.', { statusCode: 403 })
try {
const user = await db.table('users')
.where('token', token)
.select('username', 'permission')
.first()
if (!user) return res.json({ success: false, description: 'Invalid token.' })
if (!user) throw new ClientError('Invalid token.', { statusCode: 403 })
const obj = {
success: true,
username: user.username,
permissions: perms.mapPermissions(user)
}
if (utils.clientVersion) obj.version = utils.clientVersion
return res.json(obj)
if (utils.clientVersion) {
obj.version = utils.clientVersion
}
await res.json(obj)
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}
self.list = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
return res.json({ success: true, token: user.token })
try {
const user = await utils.authorize(req)
await res.json({ success: true, token: user.token })
} catch (error) {
return apiErrorsHandler(error, req, res, next)
}
}
self.change = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) return
const newToken = await self.generateUniqueToken()
if (!newToken) {
return res.json({
success: false,
description: 'Sorry, we could not allocate a unique token. Try again?'
})
}
try {
const user = await utils.authorize(req)
const newToken = await self.generateUniqueToken()
if (!newToken) {
throw new ServerError('Failed to allocate a unique token. Try again?')
}
await db.table('users')
.where('token', user.token)
.update({
@ -89,13 +93,9 @@ self.change = async (req, res, next) => {
})
self.onHold.delete(newToken)
return res.json({
success: true,
token: newToken
})
await res.json({ success: true, token: newToken })
} catch (error) {
logger.error(error)
return res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
return apiErrorsHandler(error, req, res, next)
}
}

997
controllers/uploadController.js
File diff suppressed because it is too large
View File

15
controllers/utils/ClientError.js

@ -0,0 +1,15 @@
class ClientError extends Error {
constructor (message, options = {}) {
super(message)
const {
statusCode
} = options
this.statusCode = statusCode !== undefined
? statusCode
: 400
}
}
module.exports = ClientError

18
controllers/utils/ServerError.js

@ -0,0 +1,18 @@
class ServerError extends Error {
constructor (message, options = {}) {
super(message)
const {
statusCode,
logStack
} = options
this.statusCode = statusCode !== undefined
? statusCode
: 500
this.logStack = logStack || false
}
}
module.exports = ServerError

0
controllers/multerStorageController.js → controllers/utils/multerStorage.js

638
controllers/utilsController.js

@ -6,6 +6,9 @@ const sharp = require('sharp')
const si = require('systeminformation')
const paths = require('./pathsController')
const perms = require('./permissionController')
const apiErrorsHandler = require('./handlers/apiErrorsHandler')
const ClientError = require('./utils/ClientError')
const ServerError = require('./utils/ServerError')
const config = require('./../config')
const logger = require('./../logger')
const db = require('knex')(config.database)
@ -30,6 +33,10 @@ const self = {
videoExts: ['.3g2', '.3gp', '.asf', '.avchd', '.avi', '.divx', '.evo', '.flv', '.h264', '.h265', '.hevc', '.m2p', '.m2ts', '.m4v', '.mk3d', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mxf', '.ogg', '.ogv', '.ps', '.qt', '.rmvb', '.ts', '.vob', '.webm', '.wmv'],
audioExts: ['.flac', '.mp3', '.wav', '.wma'],
stripTagsBlacklistedExts: Array.isArray(config.uploads.stripTags.blacklistExtensions)
? config.uploads.stripTags.blacklistExtensions
: [],
thumbsSize: config.uploads.generateThumbs.size || 200,
ffprobe: promisify(ffmpeg.ffprobe),
@ -75,41 +82,45 @@ const cloudflareAuth = config.cloudflare && config.cloudflare.zoneId &&
(config.cloudflare.apiKey && config.cloudflare.email))
self.mayGenerateThumb = extname => {
extname = extname.toLowerCase()
return (config.uploads.generateThumbs.image && self.imageExts.includes(extname)) ||
(config.uploads.generateThumbs.video && self.videoExts.includes(extname))
}
// Expand if necessary (must be lower case); for now only preserves some known tarballs
const extPreserves = ['.tar.gz', '.tar.z', '.tar.bz2', '.tar.lzma', '.tar.lzo', '.tar.xz']
// Expand if necessary (should be case-insensitive)
const extPreserves = [
/\.tar\.\w+/i // tarballs
]
self.extname = filename => {
self.extname = (filename, lower) => {
// Always return blank string if the filename does not seem to have a valid extension
// Files such as .DS_Store (anything that starts with a dot, without any extension after) will still be accepted
if (!/\../.test(filename)) return ''
let lower = filename.toLowerCase() // due to this, the returned extname will always be lower case
let multi = ''
let extname = ''
// check for multi-archive extensions (.001, .002, and so on)
if (/\.\d{3}$/.test(lower)) {
multi = lower.slice(lower.lastIndexOf('.') - lower.length)
lower = lower.slice(0, lower.lastIndexOf('.'))
if (/\.\d{3}$/.test(filename)) {
multi = filename.slice(filename.lastIndexOf('.') - filename.length)
filename = filename.slice(0, filename.lastIndexOf('.'))
}
// check against extensions that must be preserved
for (const extPreserve of extPreserves) {
if (lower.endsWith(extPreserve)) {
extname = extPreserve
const match = filename.match(extPreserve)
if (match && match[0]) {
extname = match[0]
break
}
}
if (!extname) {
extname = lower.slice(lower.lastIndexOf('.') - lower.length) // path.extname(lower)
extname = filename.slice(filename.lastIndexOf('.') - filename.length)
}
return extname + multi
const str = extname + multi
return lower ? str.toLowerCase() : str
}
self.escape = string => {
@ -176,34 +187,30 @@ self.stripIndents = string => {
return result
}
self.authorize = async (req, res) => {
// TODO: Improve usage of this function by the other APIs
const token = req.headers.token
if (token === undefined) {
res.status(401).json({ success: false, description: 'No token provided.' })
return
}
try {
const user = await db.table('users')
.where('token', token)
.first()
if (user) {
if (user.enabled === false || user.enabled === 0) {
res.json({ success: false, description: 'This account has been disabled.' })
return
}
return user
self.assertUser = async token => {
const user = await db.table('users')
.where('token', token)
.first()
if (user) {
if (user.enabled === false || user.enabled === 0) {
throw new ClientError('This account has been disabled.', { statusCode: 403 })
}
return user
} else {
throw new ClientError('Invalid token.', { statusCode: 403 })
}
}
res.status(401).json({ success: false, description: 'Invalid token.' })
} catch (error) {
logger.error(error)
res.status(500).json({ success: false, description: 'An unexpected error occurred. Try again?' })
self.authorize = async req => {
const token = req.headers.token
if (token === undefined) {
throw new ClientError('No token provided.', { statusCode: 403 })
}
return self.assertUser(token)
}
self.generateThumbs = async (name, extname, force) => {
extname = extname.toLowerCase()
const thumbname = path.join(paths.thumbs, name.slice(0, -extname.length) + '.png')
try {
@ -218,7 +225,7 @@ self.generateThumbs = async (name, extname, force) => {
return true
}
} catch (error) {
// Re-throw error
// Re-throw non-ENOENT error
if (error.code !== 'ENOENT') throw error
}
@ -265,12 +272,12 @@ self.generateThumbs = async (name, extname, force) => {
const duration = parseInt(metadata.format.duration)
if (isNaN(duration)) {
throw 'Warning: File does not have valid duration metadata'
throw new Error('File does not have valid duration metadata')
}
const videoStream = metadata.streams && metadata.streams.find(s => s.codec_type === 'video')
if (!videoStream || !videoStream.width || !videoStream.height) {
throw 'Warning: File does not have valid video stream metadata'
throw new Error('File does not have valid video stream metadata')
}
await new Promise((resolve, reject) => {
@ -297,7 +304,7 @@ self.generateThumbs = async (name, extname, force) => {
return true
} catch (err) {
if (err.code === 'ENOENT') {
throw error || 'Warning: FFMPEG exited with empty output file'
throw error || new Error('FFMPEG exited with empty output file')
} else {
throw error || err
}