diff options
| author | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
|---|---|---|
| committer | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
| commit | fcd39dc550dec8dbcb8325e07e938c5024cbc33d (patch) | |
| tree | f41acb4e0d5fd3c3b1236fe4324b3fef9ec6eafe /src/api/routes | |
| parent | Create FUNDING.yml (diff) | |
| parent | chore: update todo (diff) | |
| download | host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.tar.xz host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.zip | |
Merge branch 'dev'
Diffstat (limited to 'src/api/routes')
46 files changed, 1909 insertions, 0 deletions
diff --git a/src/api/routes/admin/banIP.js b/src/api/routes/admin/banIP.js new file mode 100644 index 0000000..692880d --- /dev/null +++ b/src/api/routes/admin/banIP.js @@ -0,0 +1,25 @@ +const Route = require('../../structures/Route'); + +class banIP extends Route { + constructor() { + super('/admin/ban/ip', 'post', { adminOnly: true }); + } + + async run(req, res, db) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { ip } = req.body; + if (!ip) return res.status(400).json({ message: 'No ip provided' }); + + try { + await db.table('bans').insert({ ip }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully banned the ip' + }); + } +} + +module.exports = banIP; diff --git a/src/api/routes/admin/fileGET.js b/src/api/routes/admin/fileGET.js new file mode 100644 index 0000000..9605da4 --- /dev/null +++ b/src/api/routes/admin/fileGET.js @@ -0,0 +1,32 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class filesGET extends Route { + constructor() { + super('/admin/file/:id', 'get', { adminOnly: true }); + } + + async run(req, res, db) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' }); + + let file = await db.table('files').where({ id }).first(); + const user = await db.table('users') + .select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin') + .where({ id: file.userId }) + .first(); + file = Util.constructFilePublicLink(file); + + // Additional relevant data + const filesFromUser = await db.table('files').where({ userId: user.id }).select('id'); + user.fileCount = filesFromUser.length; + + return res.json({ + message: 'Successfully retrieved file', + file, + user + }); + } +} + +module.exports = filesGET; diff --git a/src/api/routes/admin/unBanIP.js b/src/api/routes/admin/unBanIP.js new file mode 100644 index 0000000..493834b --- /dev/null +++ b/src/api/routes/admin/unBanIP.js @@ -0,0 +1,27 @@ +const Route = require('../../structures/Route'); + +class unBanIP extends Route { + constructor() { + super('/admin/unban/ip', 'post', { adminOnly: true }); + } + + async run(req, res, db) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { ip } = req.body; + if (!ip) return res.status(400).json({ message: 'No ip provided' }); + + try { + await db.table('bans') + .where({ ip }) + .delete(); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully unbanned the ip' + }); + } +} + +module.exports = unBanIP; diff --git a/src/api/routes/admin/userDemote.js b/src/api/routes/admin/userDemote.js new file mode 100644 index 0000000..b430a48 --- /dev/null +++ b/src/api/routes/admin/userDemote.js @@ -0,0 +1,28 @@ +const Route = require('../../structures/Route'); + +class userDemote extends Route { + constructor() { + super('/admin/users/demote', 'post', { adminOnly: true }); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { id } = req.body; + if (!id) return res.status(400).json({ message: 'No id provided' }); + if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' }); + + try { + await db.table('users') + .where({ id }) + .update({ isAdmin: false }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully demoted user' + }); + } +} + +module.exports = userDemote; diff --git a/src/api/routes/admin/userDisable.js b/src/api/routes/admin/userDisable.js new file mode 100644 index 0000000..e39c811 --- /dev/null +++ b/src/api/routes/admin/userDisable.js @@ -0,0 +1,28 @@ +const Route = require('../../structures/Route'); + +class userDisable extends Route { + constructor() { + super('/admin/users/disable', 'post', { adminOnly: true }); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { id } = req.body; + if (!id) return res.status(400).json({ message: 'No id provided' }); + if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' }); + + try { + await db.table('users') + .where({ id }) + .update({ enabled: false }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully disabled user' + }); + } +} + +module.exports = userDisable; diff --git a/src/api/routes/admin/userEnable.js b/src/api/routes/admin/userEnable.js new file mode 100644 index 0000000..cff622f --- /dev/null +++ b/src/api/routes/admin/userEnable.js @@ -0,0 +1,28 @@ +const Route = require('../../structures/Route'); + +class userEnable extends Route { + constructor() { + super('/admin/users/enable', 'post', { adminOnly: true }); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { id } = req.body; + if (!id) return res.status(400).json({ message: 'No id provided' }); + if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' }); + + try { + await db.table('users') + .where({ id }) + .update({ enabled: true }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully enabled user' + }); + } +} + +module.exports = userEnable; diff --git a/src/api/routes/admin/userGET.js b/src/api/routes/admin/userGET.js new file mode 100644 index 0000000..48c6e9b --- /dev/null +++ b/src/api/routes/admin/userGET.js @@ -0,0 +1,37 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class usersGET extends Route { + constructor() { + super('/admin/users/:id', 'get', { adminOnly: true }); + } + + async run(req, res, db) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid user ID supplied' }); + + try { + const user = await db.table('users') + .select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin') + .where({ id }) + .first(); + const files = await db.table('files') + .where({ userId: user.id }) + .orderBy('id', 'desc'); + + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + return res.json({ + message: 'Successfully retrieved user', + user, + files + }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = usersGET; diff --git a/src/api/routes/admin/userPromote.js b/src/api/routes/admin/userPromote.js new file mode 100644 index 0000000..4a5ed88 --- /dev/null +++ b/src/api/routes/admin/userPromote.js @@ -0,0 +1,28 @@ +const Route = require('../../structures/Route'); + +class userPromote extends Route { + constructor() { + super('/admin/users/promote', 'post', { adminOnly: true }); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { id } = req.body; + if (!id) return res.status(400).json({ message: 'No id provided' }); + if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' }); + + try { + await db.table('users') + .where({ id }) + .update({ isAdmin: true }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully promoted user' + }); + } +} + +module.exports = userPromote; diff --git a/src/api/routes/admin/userPurge.js b/src/api/routes/admin/userPurge.js new file mode 100644 index 0000000..90f6ec9 --- /dev/null +++ b/src/api/routes/admin/userPurge.js @@ -0,0 +1,26 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class userDemote extends Route { + constructor() { + super('/admin/users/purge', 'post', { adminOnly: true }); + } + + async run(req, res) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { id } = req.body; + if (!id) return res.status(400).json({ message: 'No id provided' }); + + try { + await Util.deleteAllFilesFromUser(id); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully deleted the user\'s files' + }); + } +} + +module.exports = userDemote; diff --git a/src/api/routes/admin/usersGET.js b/src/api/routes/admin/usersGET.js new file mode 100644 index 0000000..52a707f --- /dev/null +++ b/src/api/routes/admin/usersGET.js @@ -0,0 +1,23 @@ +const Route = require('../../structures/Route'); + +class usersGET extends Route { + constructor() { + super('/admin/users', 'get', { adminOnly: true }); + } + + async run(req, res, db) { + try { + const users = await db.table('users') + .select('id', 'username', 'enabled', 'isAdmin', 'createdAt'); + + return res.json({ + message: 'Successfully retrieved users', + users + }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = usersGET; diff --git a/src/api/routes/albums/albumDELETE.js b/src/api/routes/albums/albumDELETE.js new file mode 100644 index 0000000..f9c22e6 --- /dev/null +++ b/src/api/routes/albums/albumDELETE.js @@ -0,0 +1,38 @@ +const Route = require('../../structures/Route'); + +class albumDELETE extends Route { + constructor() { + super('/album/:id', 'delete'); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid album ID supplied' }); + + /* + Check if the album exists + */ + const album = await db.table('albums').where({ id, userId: user.id }).first(); + if (!album) return res.status(400).json({ message: 'The album doesn\'t exist or doesn\'t belong to the user' }); + + try { + // Delete the album + await db.table('albums').where({ id }).delete(); + + // Delete the relation of any files attached to this album + await db.table('albumsFiles').where({ albumId: id }).delete(); + + // Delete the relation of any links attached to this album + await db.table('albumsLinks').where({ albumId: id }).delete(); + + // Delete any album links created for this album + await db.table('links').where({ albumId: id }).delete(); + + return res.json({ message: 'The album was deleted successfully' }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = albumDELETE; diff --git a/src/api/routes/albums/albumEditPOST.js b/src/api/routes/albums/albumEditPOST.js new file mode 100644 index 0000000..1022bbd --- /dev/null +++ b/src/api/routes/albums/albumEditPOST.js @@ -0,0 +1,33 @@ +const Route = require('../../structures/Route'); + +class albumEditPOST extends Route { + constructor() { + super('/album/edit', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { id, name, nsfw } = req.body; + if (!id) return res.status(400).json({ message: 'Invalid album identifier supplied' }); + + + const album = await db.table('albums').where({ id, userId: user.id }).first(); + if (!album) return res.status(400).json({ message: 'The album doesn\'t exist or doesn\'t belong to the user' }); + + try { + const updateObj = { + name: name || album.name, + nsfw: nsfw === true ? true : nsfw === false ? false : album.nsfw + }; + await db + .table('albums') + .where({ id }) + .update(updateObj); + return res.json({ message: 'Editing the album was successful', data: updateObj }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = albumEditPOST; diff --git a/src/api/routes/albums/albumFullGET.js b/src/api/routes/albums/albumFullGET.js new file mode 100644 index 0000000..d25fe15 --- /dev/null +++ b/src/api/routes/albums/albumFullGET.js @@ -0,0 +1,58 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class albumGET extends Route { + constructor() { + super('/album/:id/full', 'get'); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid id supplied' }); + + const album = await db + .table('albums') + .where({ id, userId: user.id }) + .first(); + if (!album) return res.status(404).json({ message: 'Album not found' }); + + let count = 0; + + let files = db + .table('albumsFiles') + .where({ albumId: id }) + .join('files', 'albumsFiles.fileId', 'files.id') + .select('files.id', 'files.name', 'files.createdAt') + .orderBy('files.id', 'desc'); + + const { page, limit = 100 } = req.query; + if (page && page >= 0) { + files = await files.offset((page - 1) * limit).limit(limit); + + const dbRes = await db + .table('albumsFiles') + .count('* as count') + .where({ albumId: id }) + .first(); + + count = dbRes.count; + } else { + files = await files; // execute the query + count = files.length; + } + + // eslint-disable-next-line no-restricted-syntax + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + return res.json({ + message: 'Successfully retrieved album', + name: album.name, + files, + count + }); + } +} + +module.exports = albumGET; diff --git a/src/api/routes/albums/albumGET.js b/src/api/routes/albums/albumGET.js new file mode 100644 index 0000000..c9f6763 --- /dev/null +++ b/src/api/routes/albums/albumGET.js @@ -0,0 +1,46 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class albumGET extends Route { + constructor() { + super('/album/:identifier', 'get', { bypassAuth: true }); + } + + async run(req, res, db) { + const { identifier } = req.params; + if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' }); + + // Make sure it exists and it's enabled + const link = await db.table('links').where({ identifier, enabled: true }).first(); + if (!link) return res.status(404).json({ message: 'The album could not be found' }); + + // Same with the album, just to make sure is not a deleted album and a leftover link + const album = await db.table('albums').where('id', link.albumId).first(); + if (!album) return res.status(404).json({ message: 'Album not found' }); + + const files = await db.table('albumsFiles') + .where({ albumId: link.albumId }) + .join('files', 'albumsFiles.fileId', 'files.id') + .select('files.name', 'files.id') + .orderBy('files.id', 'desc'); + + // Create the links for each file + // eslint-disable-next-line no-restricted-syntax + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + // Add 1 more view to the link + await db.table('links').where({ identifier }).update('views', Number(link.views) + 1); + + return res.json({ + message: 'Successfully retrieved files', + name: album.name, + downloadEnabled: link.enableDownload, + isNsfw: album.nsfw, + files + }); + } +} + +module.exports = albumGET; diff --git a/src/api/routes/albums/albumPOST.js b/src/api/routes/albums/albumPOST.js new file mode 100644 index 0000000..52352a1 --- /dev/null +++ b/src/api/routes/albums/albumPOST.js @@ -0,0 +1,39 @@ +const moment = require('moment'); +const Route = require('../../structures/Route'); + +class albumPOST extends Route { + constructor() { + super('/album/new', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { name } = req.body; + if (!name) return res.status(400).json({ message: 'No name provided' }); + + /* + Check that an album with that name doesn't exist yet + */ + const album = await db + .table('albums') + .where({ name, userId: user.id }) + .first(); + if (album) return res.status(401).json({ message: "There's already an album with that name" }); + + const now = moment.utc().toDate(); + const insertObj = { + name, + userId: user.id, + createdAt: now, + editedAt: now + }; + + const dbRes = await db.table('albums').insert(insertObj); + + insertObj.id = dbRes.pop(); + + return res.json({ message: 'The album was created successfully', data: insertObj }); + } +} + +module.exports = albumPOST; diff --git a/src/api/routes/albums/albumPurgeDELETE.js b/src/api/routes/albums/albumPurgeDELETE.js new file mode 100644 index 0000000..a63eafc --- /dev/null +++ b/src/api/routes/albums/albumPurgeDELETE.js @@ -0,0 +1,29 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class albumDELETE extends Route { + constructor() { + super('/album/:id/purge', 'delete'); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid album ID supplied' }); + + /* + Check if the album exists + */ + const album = await db.table('albums').where({ id, userId: user.id }).first(); + if (!album) return res.status(400).json({ message: 'The album doesn\'t exist or doesn\'t belong to the user' }); + + try { + await Util.deleteAllFilesFromAlbum(id); + await db.table('albums').where({ id }).delete(); + return res.json({ message: 'The album was deleted successfully' }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = albumDELETE; diff --git a/src/api/routes/albums/albumZipGET.js b/src/api/routes/albums/albumZipGET.js new file mode 100644 index 0000000..c560cff --- /dev/null +++ b/src/api/routes/albums/albumZipGET.js @@ -0,0 +1,89 @@ +const path = require('path'); +const jetpack = require('fs-jetpack'); +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); +const log = require('../../utils/Log'); + +class albumGET extends Route { + constructor() { + super('/album/:identifier/zip', 'get', { bypassAuth: true }); + } + + async run(req, res, db) { + const { identifier } = req.params; + if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' }); + + // TODO: Do we really want to let anyone create a zip of an album? + /* + Make sure it exists and it's enabled + */ + const link = await db.table('links') + .where({ + identifier, + enabled: true, + enableDownload: true + }) + .first(); + if (!link) return res.status(400).json({ message: 'The supplied identifier could not be found' }); + + /* + Same with the album, just to make sure is not a deleted album and a leftover link + */ + const album = await db.table('albums') + .where('id', link.albumId) + .first(); + if (!album) return res.status(400).json({ message: 'Album not found' }); + + /* + If the date when the album was zipped is greater than the album's last edit, we just send the zip to the user + */ + if (album.zippedAt > album.editedAt) { + const filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`); + const exists = await jetpack.existsAsync(filePath); + /* + Make sure the file exists just in case, and if not, continue to it's generation. + */ + if (exists) { + const fileName = `${process.env.SERVICE_NAME}-${identifier}.zip`; + return res.download(filePath, fileName); + } + } + + /* + Grab the files in a very unoptimized way. (This should be a join between both tables) + */ + const fileList = await db.table('albumsFiles') + .where('albumId', link.albumId) + .select('fileId'); + + /* + If there are no files, stop here + */ + if (!fileList || !fileList.length) return res.status(400).json({ message: 'Can\'t download an empty album' }); + + /* + Get the actual files + */ + const fileIds = fileList.map(el => el.fileId); + const files = await db.table('files') + .whereIn('id', fileIds) + .select('name'); + const filesToZip = files.map(el => el.name); + + try { + Util.createZip(filesToZip, album); + await db.table('albums') + .where('id', link.albumId) + .update('zippedAt', db.fn.now()); + + const filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`); + const fileName = `${process.env.SERVICE_NAME}-${identifier}.zip`; + return res.download(filePath, fileName); + } catch (error) { + log.error(error); + return res.status(500).json({ message: 'There was a problem downloading the album' }); + } + } +} + +module.exports = albumGET; diff --git a/src/api/routes/albums/albumsGET.js b/src/api/routes/albums/albumsGET.js new file mode 100644 index 0000000..3c18d8f --- /dev/null +++ b/src/api/routes/albums/albumsGET.js @@ -0,0 +1,71 @@ +/* eslint-disable max-classes-per-file */ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class albumsGET extends Route { + constructor() { + super('/albums/mini', 'get'); + } + + async run(req, res, db, user) { + /* + Let's fetch the albums. This route will only return a small portion + of the album files for displaying on the dashboard. It's probably useless + for anyone consuming the API outside of the chibisafe frontend. + */ + const albums = await db + .table('albums') + .where('albums.userId', user.id) + .select('id', 'name', 'nsfw', 'createdAt', 'editedAt') + .orderBy('createdAt', 'desc'); + + for (const album of albums) { + // Fetch the total amount of files each album has. + const fileCount = await db // eslint-disable-line no-await-in-loop + .table('albumsFiles') + .where('albumId', album.id) + .count({ count: 'id' }); + + // Fetch the file list from each album but limit it to 5 per album + const files = await db // eslint-disable-line no-await-in-loop + .table('albumsFiles') + .join('files', { 'files.id': 'albumsFiles.fileId' }) + .where('albumId', album.id) + .select('files.id', 'files.name') + .orderBy('albumsFiles.id', 'desc') + .limit(5); + + // Fetch thumbnails and stuff + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + album.fileCount = fileCount[0].count; + album.files = files; + } + + return res.json({ + message: 'Successfully retrieved albums', + albums + }); + } +} + +class albumsDropdownGET extends Route { + constructor() { + super('/albums/dropdown', 'get', { canApiKey: true }); + } + + async run(req, res, db, user) { + const albums = await db + .table('albums') + .where('userId', user.id) + .select('id', 'name'); + return res.json({ + message: 'Successfully retrieved albums', + albums + }); + } +} + +module.exports = [albumsGET, albumsDropdownGET]; diff --git a/src/api/routes/albums/link/linkDELETE.js b/src/api/routes/albums/link/linkDELETE.js new file mode 100644 index 0000000..1af704e --- /dev/null +++ b/src/api/routes/albums/link/linkDELETE.js @@ -0,0 +1,35 @@ +const Route = require('../../../structures/Route'); + +class linkDELETE extends Route { + constructor() { + super('/album/link/delete/:identifier', 'delete'); + } + + async run(req, res, db, user) { + const { identifier } = req.params; + if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' }); + + try { + const link = await db.table('links') + .where({ identifier, userId: user.id }) + .first(); + + if (!link) return res.status(400).json({ message: 'Identifier doesn\'t exist or doesnt\'t belong to the user' }); + + await db.table('links') + .where({ id: link.id }) + .delete(); + await db.table('albumsLinks') + .where({ linkId: link.id }) + .delete(); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully deleted link' + }); + } +} + +module.exports = linkDELETE; diff --git a/src/api/routes/albums/link/linkEditPOST.js b/src/api/routes/albums/link/linkEditPOST.js new file mode 100644 index 0000000..97122a2 --- /dev/null +++ b/src/api/routes/albums/link/linkEditPOST.js @@ -0,0 +1,38 @@ +const Route = require('../../../structures/Route'); + +class linkEditPOST extends Route { + constructor() { + super('/album/link/edit', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { identifier, enableDownload, expiresAt } = req.body; + if (!identifier) return res.status(400).json({ message: 'Invalid album identifier supplied' }); + + /* + Make sure the link exists + */ + const link = await db + .table('links') + .where({ identifier, userId: user.id }) + .first(); + if (!link) return res.status(400).json({ message: "The link doesn't exist or doesn't belong to the user" }); + + try { + const updateObj = { + enableDownload: enableDownload || false, + expiresAt // This one should be null if not supplied + }; + await db + .table('links') + .where({ identifier }) + .update(updateObj); + return res.json({ message: 'Editing the link was successful', data: updateObj }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = linkEditPOST; diff --git a/src/api/routes/albums/link/linkPOST.js b/src/api/routes/albums/link/linkPOST.js new file mode 100644 index 0000000..28e9dfe --- /dev/null +++ b/src/api/routes/albums/link/linkPOST.js @@ -0,0 +1,78 @@ +const Route = require('../../../structures/Route'); +const Util = require('../../../utils/Util'); + +class linkPOST extends Route { + constructor() { + super('/album/link/new', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { albumId } = req.body; + if (!albumId) return res.status(400).json({ message: 'No album provided' }); + + /* + Make sure the album exists + */ + const exists = await db + .table('albums') + .where({ id: albumId, userId: user.id }) + .first(); + if (!exists) return res.status(400).json({ message: 'Album doesn\t exist' }); + + /* + Count the amount of links created for that album already and error out if max was reached + */ + const count = await db + .table('links') + .where('albumId', albumId) + .count({ count: 'id' }) + .first(); + if (count >= parseInt(process.env.MAX_LINKS_PER_ALBUM, 10)) return res.status(400).json({ message: 'Maximum links per album reached' }); + + let { identifier } = req.body; + if (identifier) { + if (!user.isAdmin) return res.status(401).json({ message: 'Only administrators can create custom links' }); + + if (!(/^[a-zA-Z0-9-_]+$/.test(identifier))) return res.status(400).json({ message: 'Only alphanumeric, dashes, and underscore characters are allowed' }); + + /* + Make sure that the id doesn't already exists in the database + */ + const idExists = await db + .table('links') + .where({ identifier }) + .first(); + + if (idExists) return res.status(400).json({ message: 'Album with this identifier already exists' }); + } else { + /* + Try to allocate a new identifier in the database + */ + identifier = await Util.getUniqueAlbumIdentifier(); + if (!identifier) return res.status(500).json({ message: 'There was a problem allocating a link for your album' }); + } + + try { + const insertObj = { + identifier, + userId: user.id, + albumId, + enabled: true, + enableDownload: true, + expiresAt: null, + views: 0 + }; + await db.table('links').insert(insertObj); + + return res.json({ + message: 'The link was created successfully', + data: insertObj + }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = linkPOST; diff --git a/src/api/routes/albums/link/linksGET.js b/src/api/routes/albums/link/linksGET.js new file mode 100644 index 0000000..edab49a --- /dev/null +++ b/src/api/routes/albums/link/linksGET.js @@ -0,0 +1,22 @@ +const Route = require('../../../structures/Route'); + +class linkPOST extends Route { + constructor() { + super('/album/:id/links', 'get'); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid id supplied' }); + + const links = await db.table('links') + .where({ albumId: id, userId: user.id }); + + return res.json({ + message: 'Successfully retrieved links', + links + }); + } +} + +module.exports = linkPOST; diff --git a/src/api/routes/auth/loginPOST.js b/src/api/routes/auth/loginPOST.js new file mode 100644 index 0000000..373252b --- /dev/null +++ b/src/api/routes/auth/loginPOST.js @@ -0,0 +1,56 @@ +const bcrypt = require('bcrypt'); +const moment = require('moment'); +const JWT = require('jsonwebtoken'); +const Route = require('../../structures/Route'); + +class loginPOST extends Route { + constructor() { + super('/auth/login', 'post', { bypassAuth: true }); + } + + async run(req, res, db) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { username, password } = req.body; + if (!username || !password) return res.status(401).json({ message: 'Invalid body provided' }); + + /* + Checks if the user exists + */ + const user = await db.table('users').where('username', username).first(); + if (!user) return res.status(401).json({ message: 'Invalid authorization' }); + + /* + Checks if the user is disabled + */ + if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' }); + + /* + Checks if the password is right + */ + const comparePassword = await bcrypt.compare(password, user.password); + if (!comparePassword) return res.status(401).json({ message: 'Invalid authorization.' }); + + /* + Create the jwt with some data + */ + const jwt = JWT.sign({ + iss: 'chibisafe', + sub: user.id, + iat: moment.utc().valueOf() + }, process.env.SECRET, { expiresIn: '30d' }); + + return res.json({ + message: 'Successfully logged in.', + user: { + id: user.id, + username: user.username, + apiKey: user.apiKey, + isAdmin: user.isAdmin + }, + token: jwt, + apiKey: user.apiKey + }); + } +} + +module.exports = loginPOST; diff --git a/src/api/routes/auth/registerPOST.js b/src/api/routes/auth/registerPOST.js new file mode 100644 index 0000000..1cf3630 --- /dev/null +++ b/src/api/routes/auth/registerPOST.js @@ -0,0 +1,59 @@ +const bcrypt = require('bcrypt'); +const moment = require('moment'); +const Route = require('../../structures/Route'); +const log = require('../../utils/Log'); + +class registerPOST extends Route { + constructor() { + super('/auth/register', 'post', { bypassAuth: true }); + } + + async run(req, res, db) { + if (process.env.USER_ACCOUNTS === 'false') return res.status(401).json({ message: 'Creation of new accounts is currently disabled' }); + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { username, password } = req.body; + if (!username || !password) return res.status(401).json({ message: 'Invalid body provided' }); + + if (username.length < 4 || username.length > 32) { + return res.status(400).json({ message: 'Username must have 4-32 characters' }); + } + if (password.length < 6 || password.length > 64) { + return res.status(400).json({ message: 'Password must have 6-64 characters' }); + } + + /* + Make sure the username doesn't exist yet + */ + const user = await db.table('users').where('username', username).first(); + if (user) return res.status(401).json({ message: 'Username already exists' }); + + /* + Hash the supplied password + */ + let hash; + try { + hash = await bcrypt.hash(password, 10); + } catch (error) { + log.error('Error generating password hash'); + log.error(error); + return res.status(401).json({ message: 'There was a problem processing your account' }); + } + + /* + Create the user + */ + const now = moment.utc().toDate(); + await db.table('users').insert({ + username, + password: hash, + passwordEditedAt: now, + createdAt: now, + editedAt: now, + enabled: true, + isAdmin: false + }); + return res.json({ message: 'The account was created successfully' }); + } +} + +module.exports = registerPOST; diff --git a/src/api/routes/files/albumAddPOST.js b/src/api/routes/files/albumAddPOST.js new file mode 100644 index 0000000..7b8acf7 --- /dev/null +++ b/src/api/routes/files/albumAddPOST.js @@ -0,0 +1,33 @@ +const Route = require('../../structures/Route'); + +class albumAddPOST extends Route { + constructor() { + super('/file/album/add', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { fileId, albumId } = req.body; + if (!fileId || !albumId) return res.status(400).json({ message: 'No id provided' }); + + // Make sure both file and album belong to the user + const file = await db.table('files').where({ id: fileId, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' }); + const album = await db.table('albums').where({ id: albumId, userId: user.id }).first(); + if (!album) return res.status(400).json({ message: 'Album doesn\'t exist.' }); + + try { + await db.table('albumsFiles') + .insert({ fileId, albumId }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully added file to album', + data: { fileId, album: { id: album.id, name: album.name } } + }); + } +} + +module.exports = albumAddPOST; diff --git a/src/api/routes/files/albumDelPOST.js b/src/api/routes/files/albumDelPOST.js new file mode 100644 index 0000000..8304163 --- /dev/null +++ b/src/api/routes/files/albumDelPOST.js @@ -0,0 +1,34 @@ +const Route = require('../../structures/Route'); + +class albumDelPOST extends Route { + constructor() { + super('/file/album/del', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { fileId, albumId } = req.body; + if (!fileId || !albumId) return res.status(400).json({ message: 'No id provided' }); + + // Make sure both file and album belong to the user + const file = await db.table('files').where({ id: fileId, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' }); + const album = await db.table('albums').where({ id: albumId, userId: user.id }).first(); + if (!album) return res.status(400).json({ message: 'Album doesn\'t exist.' }); + + try { + await db.table('albumsFiles') + .where({ fileId, albumId }) + .delete(); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully removed file from album', + data: { fileId, album: { id: album.id, name: album.name } } + }); + } +} + +module.exports = albumDelPOST; diff --git a/src/api/routes/files/fileDELETE.js b/src/api/routes/files/fileDELETE.js new file mode 100644 index 0000000..e467601 --- /dev/null +++ b/src/api/routes/files/fileDELETE.js @@ -0,0 +1,33 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); +const log = require('../../utils/Log'); + +class fileDELETE extends Route { + constructor() { + super('/file/:id', 'delete', { canApiKey: true }); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' }); + + /* + Make sure the file exists + */ + const file = await db.table('files').where({ id, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' }); + + /* + Delete the file + */ + try { + await Util.deleteFile(file.name, true); + return res.json({ message: 'The file was deleted successfully' }); + } catch (error) { + log.error(error); + return res.json({ message: 'There was a problem deleting the file' }); + } + } +} + +module.exports = fileDELETE; diff --git a/src/api/routes/files/fileGET.js b/src/api/routes/files/fileGET.js new file mode 100644 index 0000000..9ec6f22 --- /dev/null +++ b/src/api/routes/files/fileGET.js @@ -0,0 +1,46 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class fileGET extends Route { + constructor() { + super('/file/:id', 'get'); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' }); + + /* + Make sure the file exists + */ + let file = await db.table('files').where({ id, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' }); + + file = Util.constructFilePublicLink(file); + + /* + Fetch the albums + */ + const albums = await db.table('albumsFiles') + .where('fileId', id) + .join('albums', 'albums.id', 'albumsFiles.albumId') + .select('albums.id', 'albums.name'); + + /* + Fetch the tags + */ + const tags = await db.table('fileTags') + .where('fileId', id) + .join('tags', 'tags.id', 'fileTags.tagId') + .select('tags.id', 'tags.uuid', 'tags.name'); + + return res.json({ + message: 'Successfully retrieved file', + file, + albums, + tags + }); + } +} + +module.exports = fileGET; diff --git a/src/api/routes/files/filesAlbumsGET.js b/src/api/routes/files/filesAlbumsGET.js new file mode 100644 index 0000000..7f1190c --- /dev/null +++ b/src/api/routes/files/filesAlbumsGET.js @@ -0,0 +1,34 @@ +const Route = require('../../structures/Route'); + +class filesGET extends Route { + constructor() { + super('/file/:id/albums', 'get'); + } + + async run(req, res, db, user) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' }); + + const file = await db.table('files').where({ id, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' }); + + let albums = []; + let albumFiles = await db.table('albumsFiles') + .where('fileId', id) + .select('albumId'); + + if (albumFiles.length) { + albumFiles = albumFiles.map(a => a.albumId); + albums = await db.table('albums') + .whereIn('id', albumFiles) + .select('id', 'name'); + } + + return res.json({ + message: 'Successfully retrieved file albums', + albums + }); + } +} + +module.exports = filesGET; diff --git a/src/api/routes/files/filesGET.js b/src/api/routes/files/filesGET.js new file mode 100644 index 0000000..9e90633 --- /dev/null +++ b/src/api/routes/files/filesGET.js @@ -0,0 +1,44 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class filesGET extends Route { + constructor() { + super('/files', 'get'); + } + + async run(req, res, db, user) { + let count = 0; + + let files = db.table('files') + .where({ userId: user.id }) + .orderBy('createdAt', 'desc'); + + const { page, limit = 100 } = req.query; + if (page && page >= 0) { + files = await files.offset((page - 1) * limit).limit(limit); + + const dbRes = await db.table('files') + .count('* as count') + .where({ userId: user.id }) + .first(); + + count = dbRes.count; + } else { + files = await files; // execute the query + count = files.length; + } + + // For each file, create the public link to be able to display the file + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + return res.json({ + message: 'Successfully retrieved files', + files, + count + }); + } +} + +module.exports = filesGET; diff --git a/src/api/routes/files/tagAddBatchPOST.js b/src/api/routes/files/tagAddBatchPOST.js new file mode 100644 index 0000000..679945d --- /dev/null +++ b/src/api/routes/files/tagAddBatchPOST.js @@ -0,0 +1,40 @@ +const Route = require('../../structures/Route'); + +class tagAddBatchPOST extends Route { + constructor() { + super('/file/tag/addBatch', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { fileId, tagNames } = req.body; + if (!fileId || !tagNames.length) return res.status(400).json({ message: 'No tags provided' }); + + // Make sure the file belongs to the user + const file = await db.table('files').where({ id: fileId, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' }); + + const errors = {}; + const addedTags = []; + for await (const tagName of tagNames) { + try { + const tag = await db.table('tags').where({ name: tagName, userId: user.id }).first(); + if (!tag) throw new Error('Tag doesn\'t exist in the database'); + await db.table('fileTags').insert({ fileId, tagId: tag.id }); + + addedTags.push(tag); + } catch (e) { + errors[tagName] = e.message; + } + } + + return res.json({ + message: 'Successfully added tags to file', + data: { fileId, tags: addedTags }, + errors + }); + // eslint-disable-next-line consistent-return + } +} + +module.exports = tagAddBatchPOST; diff --git a/src/api/routes/files/tagAddPOST.js b/src/api/routes/files/tagAddPOST.js new file mode 100644 index 0000000..2bbfa07 --- /dev/null +++ b/src/api/routes/files/tagAddPOST.js @@ -0,0 +1,36 @@ +const Route = require('../../structures/Route'); + +class tagAddPOST extends Route { + constructor() { + super('/file/tag/add', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + + const { fileId, tagName } = req.body; + if (!fileId || !tagName.length) return res.status(400).json({ message: 'No tag provided' }); + + // Make sure the file belongs to the user + const file = await db.table('files').where({ id: fileId, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' }); + + // Make sure user has a tag like that + const tag = await db.table('tags').where({ name: tagName, userId: user.id }).first(); + if (!tag) return res.status(400).json({ message: 'Tag doesn\'t exist. ' }); + + try { + await db.table('fileTags').insert({ fileId, tagId: tag.id }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully added tag to file', + data: { fileId, tag } + }); + // eslint-disable-next-line consistent-return + } +} + +module.exports = tagAddPOST; diff --git a/src/api/routes/files/tagDelPOST.js b/src/api/routes/files/tagDelPOST.js new file mode 100644 index 0000000..ac0bfe4 --- /dev/null +++ b/src/api/routes/files/tagDelPOST.js @@ -0,0 +1,38 @@ +const Route = require('../../structures/Route'); + +class tagDelPost extends Route { + constructor() { + super('/file/tag/del', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + + const { fileId, tagName } = req.body; + if (!fileId || !tagName.length) return res.status(400).json({ message: 'No tag provided' }); + + // Make sure the file belongs to the user + const file = await db.table('files').where({ id: fileId, userId: user.id }).first(); + if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' }); + + // Make sure user has a tag like that + const tag = await db.table('tags').where({ name: tagName, userId: user.id }).first(); + if (!tag) return res.status(400).json({ message: 'Tag doesn\'t exist. ' }); + + try { + await db.table('fileTags') + .where({ fileId, tagId: tag.id }) + .delete(); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully removed tag from file', + data: { fileId, tag } + }); + // eslint-disable-next-line consistent-return + } +} + +module.exports = tagDelPost; diff --git a/src/api/routes/search/searchGET.js b/src/api/routes/search/searchGET.js new file mode 100644 index 0000000..40107d8 --- /dev/null +++ b/src/api/routes/search/searchGET.js @@ -0,0 +1,63 @@ +const searchQuery = require('search-query-parser'); + +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +const queryHelper = require('../../utils/QueryHelper'); + +const options = { + keywords: ['album', 'tag', 'before', 'after', 'file'], + offsets: false, + alwaysArray: true, + tokenize: true +}; + +class configGET extends Route { + constructor() { + super('/search/', 'get'); + } + + async run(req, res, db, user) { + let count = 0; + + const { q } = req.query; + const parsed = searchQuery.parse(q, options); + + let files = db.table('files') + .select('*') + .where({ 'files.userId': user.id }) + .orderBy('files.createdAt', 'desc'); + + files = queryHelper.processQuery(db, files, parsed); + + const query = files.toString(); + const { page, limit = 100 } = req.query; + + if (page && page >= 0) { + let dbRes = files.clone(); // clone the query to attach a count to it later on + files = await files.offset((page - 1) * limit).limit(limit); + + dbRes = await dbRes.count('* as count').first(); + + count = dbRes.count; + } else { + files = await files; // execute the query + count = files.length; + } + + // For each file, create the public link to be able to display the file + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + return res.json({ + message: 'Successfully retrieved files', + query, + parsed, + files, + count + }); + } +} + +module.exports = configGET; diff --git a/src/api/routes/service/configGET.js b/src/api/routes/service/configGET.js new file mode 100644 index 0000000..bc91a7e --- /dev/null +++ b/src/api/routes/service/configGET.js @@ -0,0 +1,27 @@ +const Route = require('../../structures/Route'); + +class configGET extends Route { + constructor() { + super('/service/config', 'get', { adminOnly: true }); + } + + run(req, res) { + return res.json({ + message: 'Successfully retrieved config', + config: { + serviceName: process.env.SERVICE_NAME, + uploadFolder: process.env.UPLOAD_FOLDER, + linksPerAlbum: parseInt(process.env.MAX_LINKS_PER_ALBUM, 10), + maxUploadSize: parseInt(process.env.MAX_SIZE, 10), + filenameLength: parseInt(process.env.GENERATED_FILENAME_LENGTH, 10), + albumLinkLength: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10), + generateThumbnails: process.env.GENERATE_THUMBNAILS === 'true', + generateZips: process.env.GENERATE_ZIPS === 'true', + publicMode: process.env.PUBLIC_MODE === 'true', + enableAccounts: process.env.USER_ACCOUNTS === 'true' + } + }); + } +} + +module.exports = configGET; diff --git a/src/api/routes/service/restartPOST.js b/src/api/routes/service/restartPOST.js new file mode 100644 index 0000000..530cc91 --- /dev/null +++ b/src/api/routes/service/restartPOST.js @@ -0,0 +1,14 @@ +const Route = require('../../structures/Route'); + +class restartPOST extends Route { + constructor() { + super('/service/restart', 'post', { adminOnly: true }); + } + + run(req, res) { + res.json({ message: 'Restarting...' }); + process.exit(0); + } +} + +module.exports = restartPOST; diff --git a/src/api/routes/service/versionGET.js b/src/api/routes/service/versionGET.js new file mode 100644 index 0000000..dfb994a --- /dev/null +++ b/src/api/routes/service/versionGET.js @@ -0,0 +1,15 @@ +const Route = require('../../structures/Route'); + +class versionGET extends Route { + constructor() { + super('/version', 'get', { bypassAuth: true }); + } + + run(req, res) { + return res.json({ + version: process.env.npm_package_version + }); + } +} + +module.exports = versionGET; diff --git a/src/api/routes/tags/tagDELETE.js b/src/api/routes/tags/tagDELETE.js new file mode 100644 index 0000000..cf74029 --- /dev/null +++ b/src/api/routes/tags/tagDELETE.js @@ -0,0 +1,37 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class tagDELETE extends Route { + constructor() { + super('/tag/:id/:purge*?', 'delete'); + } + + async run(req, res, db, user) { + const { id, purge } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid tag supplied' }); + + /* + Check if the tag exists + */ + const tag = await db.table('tags').where({ id, userId: user.id }).first(); + if (!tag) return res.status(400).json({ message: 'The tag doesn\'t exist or doesn\'t belong to the user' }); + + try { + /* + Should we also delete every file of that tag? + */ + if (purge) { + await Util.deleteAllFilesFromTag(id); + } + /* + Delete the tag + */ + await db.table('tags').where({ id }).delete(); + return res.json({ message: 'The tag was deleted successfully', data: tag }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = tagDELETE; diff --git a/src/api/routes/tags/tagPOST.js b/src/api/routes/tags/tagPOST.js new file mode 100644 index 0000000..89b296d --- /dev/null +++ b/src/api/routes/tags/tagPOST.js @@ -0,0 +1,36 @@ +const moment = require('moment'); +const Route = require('../../structures/Route'); + +class tagPOST extends Route { + constructor() { + super('/tag/new', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { name } = req.body; + if (!name) return res.status(400).json({ message: 'No name provided' }); + + /* + Check that a tag with that name doesn't exist yet + */ + const tag = await db.table('tags').where({ name, userId: user.id }).first(); + if (tag) return res.status(401).json({ message: 'There\'s already a tag with that name' }); + + const now = moment.utc().toDate(); + const insertObj = { + name, + userId: user.id, + createdAt: now, + editedAt: now + }; + + const dbRes = await db.table('tags').insert(insertObj); + + insertObj.id = dbRes.pop(); + + return res.json({ message: 'The tag was created successfully', data: insertObj }); + } +} + +module.exports = tagPOST; diff --git a/src/api/routes/tags/tagsGET.js b/src/api/routes/tags/tagsGET.js new file mode 100644 index 0000000..329d789 --- /dev/null +++ b/src/api/routes/tags/tagsGET.js @@ -0,0 +1,30 @@ +const Route = require('../../structures/Route'); + +class tagsGET extends Route { + constructor() { + super('/tags', 'get'); + } + + async run(req, res, db, user) { + try { + const tags = await db.table('tags') + .where('userId', user.id); + + for (const tag of tags) { + const files = await db.table('fileTags') + .where({ tagId: tag.id }); + + tag.count = files.length ? files.length : 0; + } + + return res.json({ + message: 'Successfully retrieved tags', + tags + }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = tagsGET; diff --git a/src/api/routes/uploads/chunksPOST.js b/src/api/routes/uploads/chunksPOST.js new file mode 100644 index 0000000..9cf7338 --- /dev/null +++ b/src/api/routes/uploads/chunksPOST.js @@ -0,0 +1,99 @@ +const path = require('path'); +const jetpack = require('fs-jetpack'); +const randomstring = require('randomstring'); +const Util = require('../../utils/Util'); +const Route = require('../../structures/Route'); + +class uploadPOST extends Route { + constructor() { + super('/upload/chunks', 'post', { + bypassAuth: true, + canApiKey: true + }); + } + + async run(req, res, db) { + const user = await Util.isAuthorized(req); + if (!user && process.env.PUBLIC_MODE === 'false') return res.status(401).json({ message: 'Not authorized to use this resource' }); + + const filename = Util.getUniqueFilename(randomstring.generate(32)); + // console.log('Files', req.body.files); + const info = { + size: req.body.files[0].size, + url: `${process.env.DOMAIN}/` + }; + + for (const chunk of req.body.files) { + const { uuid } = chunk; + // console.log('Chunk', chunk); + + const chunkOutput = path.join(__dirname, + '../../../../', + process.env.UPLOAD_FOLDER, + 'chunks', + uuid); + const chunkDir = await jetpack.list(chunkOutput); + const ext = path.extname(chunkDir[0]); + const output = path.join(__dirname, + '../../../../', + process.env.UPLOAD_FOLDER, + `${filename}${ext || ''}`); + chunkDir.sort(); + + // Save some data + info.name = `${filename}${ext || ''}`; + info.url += `${filename}${ext || ''}`; + info.data = chunk; + + for (let i = 0; i < chunkDir.length; i++) { + const dir = path.join(__dirname, + '../../../../', + process.env.UPLOAD_FOLDER, + 'chunks', + uuid, + chunkDir[i]); + const file = await jetpack.readAsync(dir, 'buffer'); + await jetpack.appendAsync(output, file); + } + await jetpack.removeAsync(chunkOutput); + } + + /* + If a file with the same hash and user is found, delete this + uploaded copy and return a link to the original + */ + info.hash = await Util.getFileHash(info.name); + let existingFile = await Util.checkIfFileExists(db, user, info.hash); + if (existingFile) { + existingFile = Util.constructFilePublicLink(existingFile); + res.json({ + message: 'Successfully uploaded the file.', + name: existingFile.name, + hash: existingFile.hash, + size: existingFile.size, + url: `${process.env.DOMAIN}/${existingFile.name}`, + deleteUrl: `${process.env.DOMAIN}/api/file/${existingFile.id}`, + repeated: true + }); + + return Util.deleteFile(info.name); + } + + // Otherwise generate thumbs and do the rest + Util.generateThumbnails(info.name); + const insertedId = await Util.saveFileToDatabase(req, res, user, db, info, { + originalname: info.data.original, mimetype: info.data.type + }); + if (!insertedId) return res.status(500).json({ message: 'There was an error saving the file.' }); + info.deleteUrl = `${process.env.DOMAIN}/api/file/${insertedId[0]}`; + Util.saveFileToAlbum(db, req.headers.albumid, insertedId); + delete info.chunk; + + return res.status(201).send({ + message: 'Sucessfully merged the chunk(s).', + ...info + }); + } +} + +module.exports = uploadPOST; diff --git a/src/api/routes/uploads/uploadPOST.js b/src/api/routes/uploads/uploadPOST.js new file mode 100644 index 0000000..449999e --- /dev/null +++ b/src/api/routes/uploads/uploadPOST.js @@ -0,0 +1,156 @@ +const path = require('path'); +const jetpack = require('fs-jetpack'); +const multer = require('multer'); +const moment = require('moment'); +const Util = require('../../utils/Util'); +const Route = require('../../structures/Route'); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: parseInt(process.env.MAX_SIZE, 10) * (1000 * 1000), + files: 1 + }, + fileFilter: (req, file, cb) => + // TODO: Enable blacklisting of files/extensions + /* + if (options.blacklist.mimes.includes(file.mimetype)) { + return cb(new Error(`${file.mimetype} is a blacklisted filetype.`)); + } else if (options.blacklist.extensions.some(ext => path.extname(file.originalname).toLowerCase() === ext)) { + return cb(new Error(`${path.extname(file.originalname).toLowerCase()} is a blacklisted extension.`)); + } + */ + cb(null, true) + +}).array('files[]'); + +/* + TODO: If source has transparency generate a png thumbnail, otherwise a jpg. + TODO: If source is a gif, generate a thumb of the first frame and play the gif on hover on the frontend. + + TODO: Think if its worth making a folder with the user uuid in uploads/ and upload the pictures there so + that this way at least not every single file will be in 1 directory + + XXX: Now that the default behaviour is to serve files with node, we can actually pull this off. + Before this, having files in subfolders meant messing with nginx and the paths, + but now it should be fairly easy to re-arrange the folder structure with express.static + I see great value in this, open to suggestions. +*/ + +class uploadPOST extends Route { + constructor() { + super('/upload', 'post', { + bypassAuth: true, + canApiKey: true + }); + } + + async run(req, res, db) { + const user = await Util.isAuthorized(req); + if (!user && process.env.PUBLIC_MODE === 'false') return res.status(401).json({ message: 'Not authorized to use this resource' }); + + const albumId = req.body.albumid || req.headers.albumid; + if (albumId && !user) return res.status(401).json({ message: 'Only registered users can upload files to an album' }); + if (albumId && user) { + const album = await db.table('albums').where({ id: albumId, userId: user.id }).first(); + if (!album) return res.status(401).json({ message: 'Album doesn\'t exist or it doesn\'t belong to the user' }); + } + + return upload(req, res, async err => { + if (err) console.error(err.message); + + let uploadedFile = {}; + let insertedId; + + // eslint-disable-next-line no-underscore-dangle + const remappedKeys = this._remapKeys(req.body); + const file = req.files[0]; + + const ext = path.extname(file.originalname); + const hash = Util.generateFileHash(file.buffer); + + const filename = Util.getUniqueFilename(file.originalname); + + /* + First let's get the hash of the file. This will be useful to check if the file + has already been upload by either the user or an anonymous user. + In case this is true, instead of uploading it again we retrieve the url + of the file that is already saved and thus don't store extra copies of the same file. + + For this we need to wait until we have a filename so that we can delete the uploaded file. + */ + const exists = await Util.checkIfFileExists(db, user, hash); + if (exists) return this.fileExists(res, exists, filename); + + if (remappedKeys && remappedKeys.uuid) { + const chunkOutput = path.join(__dirname, + '../../../../', + process.env.UPLOAD_FOLDER, + 'chunks', + remappedKeys.uuid, + `${remappedKeys.chunkindex.padStart(3, 0)}${ext || ''}`); + await jetpack.writeAsync(chunkOutput, file.buffer); + } else { + const output = path.join(__dirname, + '../../../../', + process.env.UPLOAD_FOLDER, + filename); + await jetpack.writeAsync(output, file.buffer); + uploadedFile = { + name: filename, + hash, + size: file.buffer.length, + url: filename + }; + } + + if (!remappedKeys || !remappedKeys.uuid) { + Util.generateThumbnails(uploadedFile.name); + insertedId = await Util.saveFileToDatabase(req, res, user, db, uploadedFile, file); + if (!insertedId) return res.status(500).json({ message: 'There was an error saving the file.' }); + uploadedFile.deleteUrl = `${process.env.DOMAIN}/api/file/${insertedId[0]}`; + + /* + If the upload had an album specified we make sure to create the relation + and update the according timestamps.. + */ + Util.saveFileToAlbum(db, albumId, insertedId); + } + + uploadedFile = Util.constructFilePublicLink(uploadedFile); + return res.status(201).send({ + message: 'Sucessfully uploaded the file.', + ...uploadedFile + }); + }); + } + + fileExists(res, exists, filename) { + exists = Util.constructFilePublicLink(exists); + res.json({ + message: 'Successfully uploaded the file.', + name: exists.name, + hash: exists.hash, + size: exists.size, + url: `${process.env.DOMAIN}/${exists.name}`, + deleteUrl: `${process.env.DOMAIN}/api/file/${exists.id}`, + repeated: true + }); + + return Util.deleteFile(filename); + } + + _remapKeys(body) { + const keys = Object.keys(body); + if (keys.length) { + for (const key of keys) { + if (!/^dz/.test(key)) continue; + body[key.replace(/^dz/, '')] = body[key]; + delete body[key]; + } + return body; + } + } +} + +module.exports = uploadPOST; diff --git a/src/api/routes/user/apiKey.js b/src/api/routes/user/apiKey.js new file mode 100644 index 0000000..653c56a --- /dev/null +++ b/src/api/routes/user/apiKey.js @@ -0,0 +1,34 @@ +const randomstring = require('randomstring'); +const moment = require('moment'); +const { dump } = require('dumper.js'); +const Route = require('../../structures/Route'); + +class apiKeyPOST extends Route { + constructor() { + super('/user/apikey/change', 'post'); + } + + async run(req, res, db, user) { + const now = moment.utc().toDate(); + const apiKey = randomstring.generate(64); + + try { + await db.table('users') + .where({ id: user.id }) + .update({ + apiKey, + apiKeyEditedAt: now + }); + } catch (error) { + dump(error); + return res.status(401).json({ message: 'There was a problem processing your account' }); + } + + return res.json({ + message: 'Successfully created new api key', + apiKey + }); + } +} + +module.exports = apiKeyPOST; diff --git a/src/api/routes/user/changePasswordPOST.js b/src/api/routes/user/changePasswordPOST.js new file mode 100644 index 0000000..82bce40 --- /dev/null +++ b/src/api/routes/user/changePasswordPOST.js @@ -0,0 +1,46 @@ +const bcrypt = require('bcrypt'); +const moment = require('moment'); +const Route = require('../../structures/Route'); +const log = require('../../utils/Log'); + +class changePasswordPOST extends Route { + constructor() { + super('/user/password/change', 'post'); + } + + async run(req, res, db, user) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { password, newPassword } = req.body; + if (!password || !newPassword) return res.status(401).json({ message: 'Invalid body provided' }); + if (password === newPassword) return res.status(400).json({ message: 'Passwords have to be different' }); + + /* + Checks if the password is right + */ + const comparePassword = await bcrypt.compare(password, user.password); + if (!comparePassword) return res.status(401).json({ message: 'Current password is incorrect' }); + + if (newPassword.length < 6 || newPassword.length > 64) { + return res.status(400).json({ message: 'Password must have 6-64 characters' }); + } + + let hash; + try { + hash = await bcrypt.hash(newPassword, 10); + } catch (error) { + log.error('Error generating password hash'); + log.error(error); + return res.status(401).json({ message: 'There was a problem processing your account' }); + } + + const now = moment.utc().toDate(); + await db.table('users').where('id', user.id).update({ + password: hash, + passwordEditedAt: now + }); + + return res.json({ message: 'The password was changed successfully' }); + } +} + +module.exports = changePasswordPOST; diff --git a/src/api/routes/user/userGET.js b/src/api/routes/user/userGET.js new file mode 100644 index 0000000..7929aac --- /dev/null +++ b/src/api/routes/user/userGET.js @@ -0,0 +1,21 @@ +const Route = require('../../structures/Route'); + +class usersGET extends Route { + constructor() { + super('/users/me', 'get'); + } + + run(req, res, db, user) { + return res.json({ + message: 'Successfully retrieved user', + user: { + id: user.id, + username: user.username, + isAdmin: user.isAdmin, + apiKey: user.apiKey + } + }); + } +} + +module.exports = usersGET; diff --git a/src/api/routes/verifyGET.js b/src/api/routes/verifyGET.js new file mode 100644 index 0000000..2f370e8 --- /dev/null +++ b/src/api/routes/verifyGET.js @@ -0,0 +1,20 @@ +const Route = require('../structures/Route'); + +class verifyGET extends Route { + constructor() { + super('/verify', 'get'); + } + + run(req, res, db, user) { + return res.json({ + message: 'Successfully verified token', + user: { + id: user.id, + username: user.username, + isAdmin: user.isAdmin + } + }); + } +} + +module.exports = verifyGET; |