More improvements to albums, and others

Improvements related to albums:

* Changed "rename album" option with a better "edit album" feature. With it you can also disable download or public link and even request a new public link (https://i.fiery.me/fz1y.png).
This also adds a new API route: /api/albums/edit.
The old API route, /api/albums/rename, is still available but will silently be using the new API in backend.

* Deleting album will now also delete its zip archive if exists.

* Renaming albums will also rename its zip archive if exists.

* Generating zip will use async fs.readFile instead of fs.readFileSync. This should improve generating speed somewhat.

* The codes that tries to generate random identifier for album will now check whether an album with the same identifier already exists. It will also rely on "uploads.maxTries" config option to limit how many times it will try to re-generate a new random identifier.

* Added a new config option "uploads.albumIdentifierLength" which sets the length of the randomly generated identifier.

* Added "download" and  "public" columns to "albums" table in database/db.js.
Existing users can run "node database/migration.js" to add the columns.

Others:

* uploadsController.getUniqueRandomName will no longer accept 3 paramters (previously it would accept a callback in the third parameter). It will now instead return a Promise.

* Album name of disabled/deleted albums will no longer be shown in uploads list.

* Added "fileLength" column to "users" table in database/db.js.

* Renamed HTTP404.html and HTTP500.html in /pages/error to 404.html and 500.html respectively. I'm still using symlinks though.

* Added a new CSS named sweetalert.css which will be used in homepage, auth and dashboard. It will style all sweetalert modals with dark theme (matching the current color scheme used in this branch).

* Updated icons (added download icon).

* Some other improvements/tweaks here and there.
This commit is contained in:
Bobby Wibowo 2018-04-29 00:26:39 +07:00
parent 8496e69955
commit 4660200b1e
No known key found for this signature in database
GPG Key ID: 51C3A1E1E22D26CF
25 changed files with 592 additions and 282 deletions

View File

@ -11,9 +11,9 @@
This branch is the one being used at [https://safe.fiery.me](https://safe.fiery.me). If you are looking for the original, head to `master` branch, or even better to [WeebDev/lolisafe](https://github.com/WeebDev/lolisafe).
If you want to use an existing lolisafe database with this branch, make sure to run `node database/migration.js` at least once to create some new columns introduced in this branch. You can ignore any errors about duplicate columns.
If you want to use an existing lolisafe database with this branch, make sure to run `node database/migration.js` at least once to create some new columns introduced in this branch.
Configuration file of lolisafe, `config.js`, is not 100% compatible with this branch. There are some options that had been renamed and/or restructured. Please make sure your config matches the sample in `config.sample.js` before starting.
Configuration file of lolisafe, `config.js`, is also not 100% compatible with this branch. There are some options that had been renamed and/or restructured. Please make sure your config matches the sample in `config.sample.js` before starting.
## Running

View File

@ -8,16 +8,21 @@ const Zip = require('jszip')
const albumsController = {}
// Let's default it to only 1 try (for missing config key)
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 maxTotalSize = config.uploads.generateZips.maxTotalSize
const maxTotalSizeBytes = parseInt(maxTotalSize) * 1000000
albumsController.list = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) { return }
const fields = ['id', 'name']
let fields = ['id', 'name']
if (req.params.sidebar === undefined) {
fields.push('timestamp')
fields.push('identifier')
fields = fields.concat(fields, ['timestamp', 'identifier', 'editedAt', 'download', 'public'])
}
const albums = await db.table('albums')
@ -34,19 +39,22 @@ albumsController.list = async (req, res, next) => {
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
album.identifier = `${homeDomain}/a/${album.identifier}`
ids.push(album.id)
}
const files = await db.table('files').whereIn('albumid', ids).select('albumid')
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 })
return res.json({ success: true, albums, homeDomain })
}
albumsController.create = async (req, res, next) => {
@ -70,19 +78,46 @@ albumsController.create = async (req, res, next) => {
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 }
await db.table('albums').insert({
name,
enabled: 1,
userid: user.id,
identifier: randomstring.generate(8),
identifier,
timestamp: Math.floor(Date.now() / 1000),
editedAt: 0,
zipGeneratedAt: 0
zipGeneratedAt: 0,
download: 1,
public: 1
})
return res.json({ success: true })
}
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 ${name} 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 }
@ -106,10 +141,7 @@ albumsController.delete = async (req, res, next) => {
failedids = await utils.bulkDeleteFilesByIds(ids, user)
if (failedids.length === ids.length) {
return res.json({
success: false,
description: 'Could not delete any of the files associated with the album.'
})
return res.json({ success: false, description: 'Could not delete any of the files associated with the album.' })
}
}
@ -118,20 +150,35 @@ albumsController.delete = async (req, res, next) => {
id,
userid: user.id
})
.update({ enabled: 0 })
.update('enabled', 0)
return res.json({
success: true,
failedids
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`)
return fs.access(zipPath, error => {
if (error) { return res.json({ success: true, failedids }) }
fs.unlink(zipPath, error => {
if (!error) { return res.json({ success: true, failedids }) }
console.log(error)
res.json({ success: false, description: error.toString(), failedids })
})
})
}
albumsController.rename = async (req, res, next) => {
albumsController.edit = async (req, res, next) => {
const user = await utils.authorize(req, res)
if (!user) { return }
const id = req.body.id
if (id === undefined || id === '') {
const id = parseInt(req.body.id)
if (isNaN(id)) {
return res.json({ success: false, description: 'No album specified.' })
}
@ -143,12 +190,15 @@ albumsController.rename = async (req, res, next) => {
const album = await db.table('albums')
.where({
name,
userid: user.id
userid: user.id,
enabled: 1
})
.first()
if (album) {
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')
@ -157,21 +207,83 @@ albumsController.rename = async (req, res, next) => {
userid: user.id
})
.update({
name
name,
download: Boolean(req.body.download),
public: Boolean(req.body.public)
})
return res.json({ success: true })
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.log(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.' }) }
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.' }) }
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')
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}`
@ -210,21 +322,20 @@ albumsController.generateZip = async (req, res, next) => {
}
if (!config.uploads.generateZips || !config.uploads.generateZips.enabled) {
return res.status(401).json({
success: false,
description: 'Zip generation disabled.'
})
return res.status(401).json({ success: false, description: 'Zip generation disabled.' })
}
const album = await db.table('albums')
.where({ identifier, enabled: 1 })
.where({
identifier,
enabled: 1
})
.first()
if (!album) {
return res.json({
success: false,
description: 'Album not found.'
})
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) {
@ -232,64 +343,66 @@ albumsController.generateZip = async (req, res, next) => {
}
if (album.zipGeneratedAt > album.editedAt) {
const filePath = path.join(config.uploads.folder, 'zips', `${identifier}.zip`)
const fileName = `${album.name}.zip`
return download(filePath, fileName)
} else {
console.log(`Generating zip for album identifier: ${identifier}`)
const files = await db.table('files')
.select('name', 'size')
.where('albumid', album.id)
if (files.length === 0) {
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)
}
}
console.log(`Generating zip for album identifier: ${identifier}`)
const files = await db.table('files')
.select('name', 'size')
.where('albumid', album.id)
if (files.length === 0) {
return res.json({ success: false, description: 'There are no files in the album.' })
}
if (maxTotalSize) {
const totalSizeBytes = files.reduce((accumulator, file) => accumulator + parseInt(file.size), 0)
if (totalSizeBytes > maxTotalSizeBytes) {
return res.json({
success: false,
description: 'There are no files in the album.'
description: `Total size of all files in the album exceeds the configured limit (${maxTotalSize}).`
})
}
}
if (config.uploads.generateZips.maxTotalSize) {
const maxTotalSizeBytes = parseInt(config.uploads.generateZips.maxTotalSize) * 1000000
const totalSizeBytes = files.reduce((accumulator, file) => accumulator + parseInt(file.size), 0)
if (totalSizeBytes > maxTotalSizeBytes) {
return res.json({
success: false,
description: `Total size of all the files in the album exceeds the limit set by the administrator (${config.uploads.generateZips.maxTotalSize}).`
})
}
}
const zipPath = path.join(zipsDir, `${album.identifier}.zip`)
const archive = new Zip()
const zipPath = path.join(__dirname, '..', config.uploads.folder, 'zips', `${album.identifier}.zip`)
const archive = new Zip()
for (const file of files) {
try {
// const exists = fs.statSync(path.join(__dirname, '..', config.uploads.folder, file.name))
archive.file(file.name, fs.readFileSync(path.join(__dirname, '..', config.uploads.folder, file.name)))
} catch (error) {
let iteration = 0
for (const file of files) {
fs.readFile(path.join(uploadsDir, file.name), (error, data) => {
if (error) {
console.log(error)
} else {
archive.file(file.name, data)
}
}
archive
.generateNodeStream({
type: 'nodebuffer',
streamFiles: true,
compression: 'DEFLATE',
compressionOptions: {
level: 1
}
})
.pipe(fs.createWriteStream(zipPath))
.on('finish', async () => {
console.log(`Generated zip for album identifier: ${identifier}`)
await db.table('albums')
.where('id', album.id)
.update({ zipGeneratedAt: Math.floor(Date.now() / 1000) })
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(`Generated zip for album identifier: ${identifier}`)
await db.table('albums')
.where('id', album.id)
.update('zipGeneratedAt', Math.floor(Date.now() / 1000))
const filePath = path.join(config.uploads.folder, 'zips', `${identifier}.zip`)
const fileName = `${album.name}.zip`
return download(filePath, fileName)
})
const filePath = path.join(zipsDir, `${identifier}.zip`)
const fileName = `${album.name}.zip`
return download(filePath, fileName)
})
}
})
}
}
@ -317,10 +430,7 @@ albumsController.addFiles = async (req, res, next) => {
.first()
if (!album) {
return res.json({
success: false,
description: 'Album doesn\'t exist or it doesn\'t belong to the user.'
})
return res.json({ success: false, description: 'Album doesn\'t exist or it doesn\'t belong to the user.' })
}
albumids.push(albumid)
@ -357,10 +467,7 @@ albumsController.addFiles = async (req, res, next) => {
.update('editedAt', Math.floor(Date.now() / 1000))
}))
return res.json({
success: true,
failedids
})
return res.json({ success: true, failedids })
}
return res.json({

View File

@ -16,10 +16,7 @@ authController.verify = async (req, res, next) => {
const user = await db.table('users').where('username', username).first()
if (!user) { return res.json({ success: false, description: 'Username doesn\'t exist.' }) }
if (user.enabled === false || user.enabled === 0) {
return res.json({
success: false,
description: 'This account has been disabled.'
})
return res.json({ success: false, description: 'This account has been disabled.' })
}
bcrypt.compare(password, user.password, (error, result) => {
@ -86,7 +83,10 @@ authController.changePassword = async (req, res, next) => {
return res.json({ success: false, description: 'Error generating password hash (╯°□°)╯︵ ┻━┻.' })
}
await db.table('users').where('id', user.id).update({ password: hash })
await db.table('users')
.where('id', user.id)
.update('password', hash)
return res.json({ success: true })
})
}
@ -117,7 +117,10 @@ authController.changeFileLength = async (req, res, next) => {
return res.json({ success: true })
}
await db.table('users').where('id', user.id).update({ fileLength })
await db.table('users')
.where('id', user.id)
.update('fileLength', fileLength)
return res.json({ success: true })
}

View File

@ -11,28 +11,23 @@ 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 uploadsDir = path.join(__dirname, '..', config.uploads.folder)
const chunkedUploads = config.uploads.chunkedUploads && config.uploads.chunkedUploads.enabled
const chunksDir = path.join(uploadDir, 'chunks')
const chunksDir = path.join(uploadsDir, '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)
return cb(null, uploadsDir)
}
// 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?')
@ -44,7 +39,9 @@ const storage = multer.diskStorage({
if (!chunkedUploads || (req.body.uuid === undefined && req.body.chunkindex === undefined)) {
const extension = path.extname(file.originalname)
const length = uploadsController.getFileNameLength(req)
return uploadsController.getUniqueRandomName(length, extension, cb)
return uploadsController.getUniqueRandomName(length, extension)
.then(name => cb(null, name))
.catch(error => cb(error))
}
// index.extension (e.i. 0, 1, ..., n - will prepend zeros depending on the amount of chunks)
@ -101,22 +98,20 @@ uploadsController.getFileNameLength = req => {
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('Sorry, we could not allocate a unique random name. Try again?')
})
}
// Get us a unique random name
access(0)
uploadsController.getUniqueRandomName = (length, extension) => {
return new Promise((resolve, reject) => {
const access = i => {
const name = randomstring.generate(length) + extension
fs.access(path.join(uploadsDir, name), error => {
if (error) { return resolve(name) }
console.log(`A file named ${name} already exists (${++i}/${maxTries}).`)
if (i < maxTries) { return access(i) }
// eslint-disable-next-line prefer-promise-reject-errors
return reject('Sorry, we could not allocate a unique random name. Try again?')
})
}
access(0)
})
}
uploadsController.upload = async (req, res, next) => {
@ -129,10 +124,7 @@ uploadsController.upload = async (req, res, next) => {
}
if (user && (user.enabled === false || user.enabled === 0)) {
return res.json({
success: false,
description: 'This account has been disabled.'
})
return res.json({ success: false, description: 'This account has been disabled.' })
}
if (user && user.fileLength && !req.headers.filelength) {
@ -181,10 +173,7 @@ uploadsController.actuallyUpload = async (req, res, user, albumid) => {
uploadsController.finishChunks = async (req, res, next) => {
if (!chunkedUploads) {
return res.json({
success: false,
description: 'Chunked uploads is disabled at the moment.'
})
return res.json({ success: false, description: 'Chunked uploads is disabled at the moment.' })
}
let user
@ -196,10 +185,7 @@ uploadsController.finishChunks = async (req, res, next) => {
}
if (user && (user.enabled === false || user.enabled === 0)) {
return res.json({
success: false,
description: 'This account has been disabled.'
})
return res.json({ success: false, description: 'This account has been disabled.' })
}
if (user && user.fileLength && !req.headers.filelength) {
@ -239,64 +225,64 @@ uploadsController.actuallyFinishChunks = async (req, res, user, albumid) => {
const extension = typeof original === 'string' ? path.extname(original) : ''
const length = uploadsController.getFileNameLength(req)
uploadsController.getUniqueRandomName(length, extension, async (error, name) => {
const name = await uploadsController.getUniqueRandomName(length, extension)
.catch(erred)
if (!name) { return }
const destination = path.join(uploadsDir, name)
const destFileStream = fs.createWriteStream(destination, { flags: 'a' })
// Sort chunk names
chunkNames.sort()
// Append all chunks
const chunksAppended = await uploadsController.appendToStream(destFileStream, uuidDir, chunkNames)
.then(() => true)
.catch(erred)
if (!chunksAppended) { return }
// Delete all chunks
const chunksDeleted = await Promise.all(chunkNames.map(chunkName => {
return new Promise((resolve, reject) => {
const chunkPath = path.join(uuidDir, chunkName)
fs.unlink(chunkPath, error => {
if (error) { return reject(error) }
resolve()
})
})
}))
.then(() => true)
.catch(erred)
if (!chunksDeleted) { return }
// Delete UUID dir
fs.rmdir(uuidDir, async error => {
if (error) { return erred(error) }
const destination = path.join(uploadDir, name)
const destFileStream = fs.createWriteStream(destination, { flags: 'a' })
const data = {
filename: name,
originalname: file.original || '',
mimetype: file.type || '',
size: file.size || 0
}
// Sort chunk names
chunkNames.sort()
data.albumid = parseInt(file.albumid)
if (isNaN(data.albumid)) { data.albumid = albumid }
// Append all chunks
const chunksAppended = await uploadsController.appendToStream(destFileStream, uuidDir, chunkNames)
.then(() => true)
.catch(erred)
if (!chunksAppended) { return }
// Delete all chunks
const chunksDeleted = await Promise.all(chunkNames.map(chunkName => {
return new Promise((resolve, reject) => {
const chunkPath = path.join(uuidDir, chunkName)
fs.unlink(chunkPath, error => {
if (error) { return reject(error) }
resolve()
})
})
}))
.then(() => true)
.catch(erred)
if (!chunksDeleted) { return }
// Delete UUID dir
fs.rmdir(uuidDir, async error => {
if (error) { return erred(error) }
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)
}
}
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)
}
}
})
})
}
@ -385,7 +371,7 @@ uploadsController.writeFilesToDb = (req, res, user, infoMap) => {
}
iteration++
if (iteration >= infoMap.length) {
if (iteration === infoMap.length) {
return resolve({ files, existingFiles })
}
})
@ -504,16 +490,10 @@ uploadsController.bulkDelete = async (req, res) => {
const failedids = await utils.bulkDeleteFilesByIds(ids, user)
if (failedids.length < ids.length) {
return res.json({
success: true,
failedids
})
return res.json({ success: true, failedids })
}
return res.json({
success: false,
description: 'Could not delete any of the selected files.'
})
return res.json({ success: false, description: 'Could not delete any of the selected files.' })
}
uploadsController.list = async (req, res) => {
@ -540,6 +520,13 @@ uploadsController.list = async (req, res) => {
.select('id', 'albumid', 'timestamp', 'name', 'userid', 'size')
const albums = await db.table('albums')
.where(function () {
this.where('enabled', 1)
if (user.username !== 'root') {
this.where('userid', user.id)
}
})
const basedomain = config.domain
const userids = []
@ -585,7 +572,8 @@ uploadsController.list = async (req, res) => {
// 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)
const users = await db.table('users')
.whereIn('id', userids)
for (const dbUser of users) {
for (const file of files) {
if (file.userid === dbUser.id) {

View File

@ -11,6 +11,8 @@ const init = function (db) {
table.integer('timestamp')
table.integer('editedAt')
table.integer('zipGeneratedAt')
table.integer('download')
table.integer('public')
}).then(() => {})
}
})
@ -41,6 +43,7 @@ const init = function (db) {
table.string('token')
table.integer('enabled')
table.integer('timestamp')
table.integer('fileLength')
}).then(() => {
db.table('users').where({ username: 'root' }).then((user) => {
if (user.length > 0) { return }

View File

@ -4,7 +4,9 @@ const db = require('knex')(config.database)
const map = {
albums: {
editedAt: 'integer',
zipGeneratedAt: 'integer'
zipGeneratedAt: 'integer',
download: 'integer',
public: 'integer'
},
users: {
enabled: 'integer',

View File

@ -63,11 +63,11 @@ for (const page of config.pages) {
}
safe.use((req, res, next) => {
res.status(404).sendFile('HTTP404.html', { root: './pages/error/' })
res.status(404).sendFile('404.html', { root: './pages/error/' })
})
safe.use((error, req, res, next) => {
console.error(error)
res.status(500).sendFile('HTTP500.html', { root: './pages/error/' })
res.status(500).sendFile('500.html', { root: './pages/error/' })
})
safe.listen(config.port, () => console.log(`lolisafe started on port ${config.port}`))

View File

@ -210,6 +210,10 @@ html {
height: 2.25em;
}
.table td a:not([href]) {
text-decoration: line-through;
}
#modal .modal-content {
background-color: #31363b;
border-radius: 5px;

124
public/css/sweetalert.css Normal file
View File

@ -0,0 +1,124 @@
.swal-modal {
background-color: #31363b;
}
.swal-title,
.swal-text {
color: #eff0f1;
}
.swal-content .label,
.swal-content .checkbox,
.swal-content .radio {
color: #eff0f1;
}
.swal-content .checkbox:hover,
.swal-content .radio:hover {
color: #bdc3c7;
}
.swal-button {
background-color: #3794d2;
color: #eff0f1;
}
.swal-button:hover {
background-color: #60a8dc;
}
.swal-button:focus {
-webkit-box-shadow: 0 0 0 1px #31363b, 0 0 0 3px rgba(55, 148, 210, 0.29);
box-shadow: 0 0 0 1px #31363b, 0 0 0 3px rgba(55, 148, 210, 0.29);
}
.swal-button--loading {
color: transparent;
}
.swal-button--danger {
background-color: #da4453;
}
.swal-icon--info {
border-color: #3794d2;
}
.swal-icon--info:after,
.swal-icon--info:before {
background-color: #3794d2;
}
.swal-icon--error {
border-color: #da4453;
}
.swal-icon--error__line {
background-color: #da4453;
}
.swal-icon--warning {
border-color: #f67400;
-webkit-animation: pulseWarning .5s infinite alternate;
animation: pulseWarning .5s infinite alternate;
}
.swal-icon--warning__body,
.swal-icon--warning__dot {
background-color: #f67400;
-webkit-animation: pulseWarningBody .5s infinite alternate;
animation: pulseWarningBody .5s infinite alternate;
}
@-webkit-keyframes pulseWarning {
0% {
border-color: #ffaa60;
}
to {
border-color: #f67400;
}
}
@keyframes pulseWarning {
0% {
border-color: #ffaa60;
}
to {
border-color: #f67400;
}
}
@-webkit-keyframes pulseWarningBody {
0% {
background-color: #ffaa60;
}
to {
background-color: #f67400;
}
}
@keyframes pulseWarningBody {
0% {
background-color: #ffaa60;
}
to {
background-color: #f67400;
}
}
.swal-icon--success {
border-color: #27ae60;
}
.swal-icon--success__line {
background-color: #27ae60;
}
.swal-icon--success__hide-corners {
background-color: #31363b;
}
.swal-icon--success::after,
.swal-icon--success::before {
background: #31363b;
}

View File

@ -10,7 +10,8 @@ const panel = {
selectedFiles: [],
selectAlbumContainer: undefined,
checkboxes: undefined,
lastSelected: undefined
lastSelected: undefined,
albums: []
}
panel.preparePage = () => {
@ -569,7 +570,6 @@ panel.addToAlbum = async (ids, album) => {
// We want to this to be re-usable
panel.selectAlbumContainer = document.createElement('div')
panel.selectAlbumContainer.id = 'selectAlbum'
panel.selectAlbumContainer.className = 'select is-fullwidth'
}
const options = list.data.albums
@ -577,12 +577,17 @@ panel.addToAlbum = async (ids, album) => {
.join('\n')
panel.selectAlbumContainer.innerHTML = `
<select>
<option value="-1">Remove from album</option>
<option value="" selected disabled>Choose an album</option>
${options}
</select>
<p class="help is-danger">If a file is already in an album, it will be moved.</p>
<div class="field">
<label class="label">If a file is already in an album, it will be moved.</label>
<div class="control">
<div class="select is-fullwidth">
<select>
<option value="-1">Remove from album</option>
<option value="" selected disabled>Choose an album</option>
${options}
</select>
</div>
</div>
`
const choose = await swal({
@ -682,9 +687,13 @@ panel.getAlbums = () => {
</div>
`
panel.albums = response.data.albums
const homeDomain = response.data.homeDomain
const table = document.getElementById('table')
for (const album of response.data.albums) {
const albumUrl = `${homeDomain}/a/${album.identifier}`
const tr = document.createElement('tr')
tr.innerHTML = `
<tr>
@ -692,18 +701,23 @@ panel.getAlbums = () => {
<th>${album.name}</th>
<th>${album.files}</th>
<td>${album.date}</td>
<td><a href="${album.identifier}" target="_blank" rel="noopener">${album.identifier}</a></td>
<td><a${album.public ? ` href="${albumUrl}"` : ''} target="_blank" rel="noopener">${albumUrl}</a></td>
<td style="text-align: right">
<a class="button is-small is-primary" title="Edit name" onclick="panel.renameAlbum(${album.id})">
<a class="button is-small is-primary" title="Edit album" onclick="panel.editAlbum(${album.id})">
<span class="icon is-small">
<i class="icon-pencil-1"></i>
</span>
</a>
<a class="button is-small is-info clipboard-js" title="Copy link to clipboard" data-clipboard-text="${album.identifier}">
<a class="button is-small is-info clipboard-js" title="Copy link to clipboard" ${album.public ? `data-clipboard-text="${album.identifier}"` : 'disabled'}>
<span class="icon is-small">
<i class="icon-clipboard-1"></i>
</span>
</a>
<a class="button is-small is-warning" title="Download album" ${album.download ? `href="api/album/zip/${album.identifier}?v=${album.editedAt}"` : 'disabled'}>
<span class="icon is-small">
<i class="icon-download"></i>
</span>
</a>
<a class="button is-small is-danger" title="Delete album" onclick="panel.deleteAlbum(${album.id})">
<span class="icon is-small">
<i class="icon-trash"></i>
@ -726,48 +740,94 @@ panel.getAlbums = () => {
})
}
panel.renameAlbum = id => {
swal({
title: 'Rename album',
text: 'New name you want to give the album:',
panel.editAlbum = async id => {
const album = panel.albums.find(a => a.id === id)
if (!album) {
return swal('An error occurred!', 'Album with that ID could not be found.', 'error')
}
const div = document.createElement('div')
div.innerHTML = `
<div class="field">
<label class="label">Album name</label>
<div class="controls">
<input id="_name" class="input" type="text" value="${album.name || 'My super album'}">
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input id="_download" type="checkbox" ${album.download ? 'checked' : ''}>
Enable download
</label>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input id="_public" type="checkbox" ${album.public ? 'checked' : ''}>
Enable public link
</label>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input id="_requestLink" type="checkbox">
Request new public link
</label>
</div>
</div>
`
const value = await swal({
title: 'Edit album',
icon: 'info',
content: {
element: 'input',
attributes: {
placeholder: 'My super album'
}
},
content: div,
buttons: {
cancel: true,
confirm: {
closeModal: false
}
}
}).then(value => {
if (!value) { return swal.close() }
axios.post('api/albums/rename', {
id,
name: value
})
.then(response => {
if (response.data.success === false) {
if (response.data.description === 'No token provided') { return panel.verifyToken(panel.token) } else if (response.data.description === 'Name already in use') { swal.showInputError('That name is already in use!') } else { swal('An error occurred!', response.data.description, 'error') }
return
}
swal('Success!', 'Your album was renamed to: ' + value, 'success')
panel.getAlbumsSidebar()
panel.getAlbums()
})
.catch(error => {
console.log(error)
return swal('An error occurred!', 'There was an error with the request, please check the console for more information.', 'error')
})
})
if (!value) { return }
const response = await axios.post('api/albums/edit', {
id,
name: document.getElementById('_name').value,
download: document.getElementById('_download').checked,
public: document.getElementById('_public').checked,
requestLink: document.getElementById('_requestLink').checked
})
.catch(error => {
console.log(error)
return swal('An error occurred!', 'There was an error with the request, please check the console for more information.', 'error')
})
if (response.data.success === false) {
if (response.data.description === 'No token provided') {
return panel.verifyToken(panel.token)
} else if (response.data.description === 'Name already in use') {
return swal.showInputError('That name is already in use!')
} else {
return swal('An error occurred!', response.data.description, 'error')
}
}
if (response.data.identifier) {
swal('Success!', `Your album's new identifier is: ${response.data.identifier}.`, 'success')
} else if (response.data.name !== album.name) {
swal('Success!', `Your album was renamed to: ${response.data.name}.`, 'success')
} else {
swal('Success!', 'Your album was edited!', 'success')
}
panel.getAlbumsSidebar()
panel.getAlbums()
}
panel.deleteAlbum = id => {
swal({
panel.deleteAlbum = async id => {
const proceed = await swal({
title: 'Are you sure?',
text: 'This won\'t delete your files, only the album!',
icon: 'warning',
@ -785,30 +845,29 @@ panel.deleteAlbum = id => {
closeModal: false
}
}
}).then(value => {
if (!value) { return }
axios.post('api/albums/delete', {
id,
purge: value === 'purge'
})
.then(response => {
if (response.data.success === false) {
if (response.data.description === 'No token provided') {
return panel.verifyToken(panel.token)
} else {
return swal('An error occurred!', response.data.description, 'error')
}
}
swal('Deleted!', 'Your album has been deleted.', 'success')
panel.getAlbumsSidebar()
panel.getAlbums()
})
.catch(error => {
console.log(error)
return swal('An error occurred!', 'There was an error with the request, please check the console for more information.', 'error')
})
})
if (!proceed) { return }
const response = await axios.post('api/albums/delete', {
id,
purge: proceed === 'purge'
})
.catch(error => {
console.log(error)
return swal('An error occurred!', 'There was an error with the request, please check the console for more information.', 'error')
})
if (response.data.success === false) {
if (response.data.description === 'No token provided') {
return panel.verifyToken(panel.token)
} else {
return swal('An error occurred!', response.data.description, 'error')
}
}
swal('Deleted!', 'Your album has been deleted.', 'success')
panel.getAlbumsSidebar()
panel.getAlbums()
}
panel.submitAlbum = element => {
@ -816,7 +875,7 @@ panel.submitAlbum = element => {
axios.post('api/albums', {
name: document.getElementById('albumName').value
})
.then(async response => {
.then(response => {
panel.isLoading(element, false)
if (response.data.success === false) {
if (response.data.description === 'No token provided') {

View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'fontello';
src: url('fontello.eot?8080531');
src: url('fontello.eot?8080531#iefix') format('embedded-opentype'),
url('fontello.woff2?8080531') format('woff2'),
url('fontello.woff?8080531') format('woff'),
url('fontello.ttf?8080531') format('truetype'),
url('fontello.svg?8080531#fontello') format('svg');
src: url('fontello.eot?61363773');
src: url('fontello.eot?61363773#iefix') format('embedded-opentype'),
url('fontello.woff2?61363773') format('woff2'),
url('fontello.woff?61363773') format('woff'),
url('fontello.ttf?61363773') format('truetype'),
url('fontello.svg?61363773#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -69,6 +69,7 @@
.icon-clipboard-1:before { content: '\e80b'; } /* '' */
.icon-login:before { content: '\e80c'; } /* '' */
.icon-home:before { content: '\e80d'; } /* '' */
.icon-download:before { content: '\e80e'; } /* '' */
.icon-help-circled:before { content: '\e80f'; } /* '' */
.icon-terminal:before { content: '\e810'; } /* '' */
.icon-github-circled:before { content: '\f09b'; } /* '' */

Binary file not shown.

View File

@ -34,6 +34,8 @@
<glyph glyph-name="home" unicode="&#xe80d;" d="M786 296v-267q0-15-11-25t-25-11h-214v214h-143v-214h-214q-15 0-25 11t-11 25v267q0 1 0 2t0 2l321 264 321-264q1-1 1-4z m124 39l-34-41q-5-5-12-6h-2q-7 0-12 3l-386 322-386-322q-7-4-13-3-7 1-12 6l-35 41q-4 6-3 13t6 12l401 334q18 15 42 15t43-15l136-113v108q0 8 5 13t13 5h107q8 0 13-5t5-13v-227l122-102q6-4 6-12t-4-13z" horiz-adv-x="928.6" />
<glyph glyph-name="download" unicode="&#xe80e;" d="M714 100q0 15-10 25t-25 11-25-11-11-25 11-25 25-11 25 11 10 25z m143 0q0 15-10 25t-26 11-25-11-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-37t-38-16h-821q-23 0-38 16t-16 37v179q0 22 16 38t38 16h259l75-76q33-32 76-32t76 32l76 76h259q22 0 38-16t16-38z m-182 318q10-23-8-39l-250-250q-10-11-25-11t-25 11l-250 250q-17 16-8 39 10 21 33 21h143v250q0 15 11 25t25 11h143q14 0 25-11t10-25v-250h143q24 0 33-21z" horiz-adv-x="928.6" />
<glyph glyph-name="help-circled" unicode="&#xe80f;" d="M500 82v107q0 8-5 13t-13 5h-107q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h107q8 0 13 5t5 13z m143 375q0 49-31 91t-77 65-95 23q-136 0-207-119-9-13 4-24l74-55q4-4 10-4 9 0 14 7 30 38 48 51 19 14 48 14 27 0 48-15t21-33q0-21-11-34t-38-25q-35-15-65-48t-29-70v-20q0-8 5-13t13-5h107q8 0 13 5t5 13q0 10 12 27t30 28q18 10 28 16t25 19 25 27 16 34 7 45z m214-107q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="terminal" unicode="&#xe810;" d="M929 779h-858c-39 0-71-32-71-72v-714c0-40 32-72 71-72h858c39 0 71 32 71 72v714c0 40-32 72-71 72z m-786-500l143 142-143 143 71 72 215-215-215-214-71 72z m571-72h-285v72h285v-72z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -16,11 +16,19 @@ routes.get('/a/:identifier', async (req, res, next) => {
}
const album = await db.table('albums')
.where({ identifier, enabled: 1 })
.where({
identifier,
enabled: 1
})
.first()
if (!album) {
return res.status(404).sendFile('404.html', { root: './pages/error/' })
} else if (album.public === 0) {
return res.status(401).json({
success: false,
description: 'This album is not available for public.'
})
}
const files = await db.table('files')
@ -61,7 +69,8 @@ routes.get('/a/:identifier', async (req, res, next) => {
thumb,
files,
identifier,
enableDownload: Boolean(config.uploads.generateZips && config.uploads.generateZips.enabled),
generateZips: config.uploads.generateZips && config.uploads.generateZips.enabled,
downloadLink: album.download === 0 ? null : `../api/album/zip/${album.identifier}?v=${album.editedAt}`,
editedAt: album.editedAt,
url: `${homeDomain}/a/${album.identifier}`
})

View File

@ -33,6 +33,7 @@ routes.get('/albums/:sidebar', (req, res, next) => albumsController.list(req, re
routes.post('/albums', (req, res, next) => albumsController.create(req, res, next))
routes.post('/albums/addfiles', (req, res, next) => albumsController.addFiles(req, res, next))
routes.post('/albums/delete', (req, res, next) => albumsController.delete(req, res, next))
routes.post('/albums/edit', (req, res, next) => albumsController.edit(req, res, next))
routes.post('/albums/rename', (req, res, next) => albumsController.rename(req, res, next))
routes.get('/albums/test', (req, res, next) => albumsController.test(req, res, next))
routes.get('/tokens', (req, res, next) => tokenController.list(req, res, next))

View File

@ -12,7 +12,7 @@
v1: CSS and JS files.
v2: Images and config files (manifest.json, browserconfig.xml, etcetera).
#}
{% set v1 = "uuEpP64ca8" %}
{% set v1 = "cDnmwkVVmk" %}
{% set v2 = "MSEpgpfFIQ" %}
{#

View File

@ -41,15 +41,19 @@
</div>
</div>
{% if enableDownload and files.length -%}
{% if generateZips and files.length -%}
<div class="level-right">
<p class="level-item">
<a class="button is-primary is-outlined" title="Download album" href="../api/album/zip/{{ identifier }}?v={{ editedAt }}">Download Album</a>
{% if downloadLink -%}
<a class="button is-primary is-outlined" title="Download album" href="{{ downloadLink }}">Download album</a>
{%- else -%}
<a class="button is-primary is-outlined" title="Download disabled" disabled>Download disabled</a>
{%- endif %}
</p>
</div>
{%- endif %}
</nav>
{% if enableDownload and files.length -%}
{% if generateZips and downloadLink and files.length -%}
<article class="message">
<div class="message-body">
Album archives may be cached by CDN, if the one you downloaded seems outdated, you should try refreshing the page to get the latest version of the download link.

View File

@ -3,6 +3,7 @@
{% block stylesheets %}
{{ super() }}
<link rel="stylesheet" type="text/css" href="libs/fontello/fontello.css?v={{ globals.v1 }}">
<link rel="stylesheet" type="text/css" href="css/sweetalert.css?v={{ globals.v1 }}">
<link rel="stylesheet" type="text/css" href="css/auth.css?v={{ globals.v1 }}">
{% endblock %}

View File

@ -3,6 +3,7 @@
{% block stylesheets %}
{{ super() }}
<link rel="stylesheet" type="text/css" href="libs/fontello/fontello.css?v={{ globals.v1 }}">
<link rel="stylesheet" type="text/css" href="css/sweetalert.css?v={{ globals.v1 }}">
<link rel="stylesheet" type="text/css" href="css/dashboard.css?v={{ globals.v1 }}">
{% endblock %}

View File

@ -3,6 +3,7 @@
{% block stylesheets %}
{{ super() }}
<link rel="stylesheet" type="text/css" href="libs/fontello/fontello.css?v={{ globals.v1 }}">
<link rel="stylesheet" type="text/css" href="css/sweetalert.css?v={{ globals.v1 }}">
<link rel="stylesheet" type="text/css" href="css/home.css?v={{ globals.v1 }}">
{% endblock %}