diff options
| author | Kana <[email protected]> | 2020-12-24 21:41:24 +0900 |
|---|---|---|
| committer | GitHub <[email protected]> | 2020-12-24 21:41:24 +0900 |
| commit | 2412a60bd4cb2364a477a3af79a8c6dcb6b0ddab (patch) | |
| tree | dbf2b2cad342f31849a62089dedd40165758af86 /src/api | |
| parent | Enable deleting files with the API key (diff) | |
| parent | bug: fix showlist resetting itself every time the page is changed (diff) | |
| download | host.fuwn.me-2412a60bd4cb2364a477a3af79a8c6dcb6b0ddab.tar.xz host.fuwn.me-2412a60bd4cb2364a477a3af79a8c6dcb6b0ddab.zip | |
Merge pull request #228 from Zephyrrus/begone_trailing_commas
Merge own dev branch into main dev branch
Diffstat (limited to 'src/api')
43 files changed, 1086 insertions, 308 deletions
diff --git a/src/api/database/migrations/20190221225812_initialMigration.js b/src/api/database/migrations/20190221225812_initialMigration.js index a27a08a..b755a33 100644 --- a/src/api/database/migrations/20190221225812_initialMigration.js +++ b/src/api/database/migrations/20190221225812_initialMigration.js @@ -1,40 +1,44 @@ -exports.up = async knex => { - await knex.schema.createTable('users', table => { +exports.up = async (knex) => { + await knex.schema.createTable('users', (table) => { table.increments(); - table.string('username'); + table.string('username').unique(); table.text('password'); table.boolean('enabled'); table.boolean('isAdmin'); - table.string('apiKey'); + table.string('apiKey').unique(); table.timestamp('passwordEditedAt'); table.timestamp('apiKeyEditedAt'); table.timestamp('createdAt'); table.timestamp('editedAt'); }); - await knex.schema.createTable('albums', table => { + await knex.schema.createTable('albums', (table) => { table.increments(); table.integer('userId'); table.string('name'); + table.boolean('nsfw').defaultTo(false); table.timestamp('zippedAt'); table.timestamp('createdAt'); table.timestamp('editedAt'); + + table.unique(['userId', 'name']); }); - await knex.schema.createTable('files', table => { + await knex.schema.createTable('files', (table) => { table.increments(); table.integer('userId'); table.string('name'); table.string('original'); table.string('type'); table.integer('size'); + table.boolean('nsfw').defaultTo(false); table.string('hash'); table.string('ip'); table.timestamp('createdAt'); table.timestamp('editedAt'); }); - await knex.schema.createTable('links', table => { + await knex.schema.createTable('links', (table) => { table.increments(); table.integer('userId'); table.integer('albumId'); @@ -45,42 +49,50 @@ exports.up = async knex => { table.timestamp('expiresAt'); table.timestamp('createdAt'); table.timestamp('editedAt'); + + table.unique(['userId', 'albumId', 'identifier']); }); - await knex.schema.createTable('albumsFiles', table => { + await knex.schema.createTable('albumsFiles', (table) => { table.increments(); table.integer('albumId'); table.integer('fileId'); + + table.unique(['albumId', 'fileId']); }); - await knex.schema.createTable('albumsLinks', table => { + await knex.schema.createTable('albumsLinks', (table) => { table.increments(); table.integer('albumId'); - table.integer('linkId'); + table.integer('linkId').unique(); }); - await knex.schema.createTable('tags', table => { + await knex.schema.createTable('tags', (table) => { table.increments(); table.string('uuid'); table.integer('userId'); table.string('name'); table.timestamp('createdAt'); table.timestamp('editedAt'); + + table.unique(['userId', 'name']); }); - await knex.schema.createTable('fileTags', table => { + await knex.schema.createTable('fileTags', (table) => { table.increments(); table.integer('fileId'); table.integer('tagId'); + + table.unique(['fileId', 'tagId']); }); - await knex.schema.createTable('bans', table => { + await knex.schema.createTable('bans', (table) => { table.increments(); table.string('ip'); table.timestamp('createdAt'); }); }; -exports.down = async knex => { +exports.down = async (knex) => { await knex.schema.dropTableIfExists('users'); await knex.schema.dropTableIfExists('albums'); await knex.schema.dropTableIfExists('files'); diff --git a/src/api/database/seeds/initial.js b/src/api/database/seeds/initial.js index 280fd74..2383a7b 100644 --- a/src/api/database/seeds/initial.js +++ b/src/api/database/seeds/initial.js @@ -1,7 +1,8 @@ +/* eslint-disable no-console */ const bcrypt = require('bcrypt'); const moment = require('moment'); -exports.seed = async db => { +exports.seed = async (db) => { const now = moment.utc().toDate(); const user = await db.table('users').where({ username: process.env.ADMIN_ACCOUNT }).first(); if (user) return; diff --git a/src/api/databaseMigration.js b/src/api/databaseMigration.js index 15b2bf7..7e919f3 100644 --- a/src/api/databaseMigration.js +++ b/src/api/databaseMigration.js @@ -1,17 +1,60 @@ +/* eslint-disable eqeqeq */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-console */ const nodePath = require('path'); const moment = require('moment'); const jetpack = require('fs-jetpack'); -const { path } = require('fs-jetpack'); const sharp = require('sharp'); const ffmpeg = require('fluent-ffmpeg'); const imageExtensions = ['.jpg', '.jpeg', '.bmp', '.gif', '.png', '.webp']; const videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov']; +const generateThumbnailForImage = async (filename, output) => { + try { + const file = await jetpack.readAsync(nodePath.join(__dirname, '../../uploads', filename), 'buffer'); + await sharp(file) + .resize(64, 64) + .toFormat('webp') + .toFile(nodePath.join(__dirname, '../../uploads/thumbs/square', output)); + await sharp(file) + .resize(225, null) + .toFormat('webp') + .toFile(nodePath.join(__dirname, '../../uploads/thumbs', output)); + console.log('finished', filename); + } catch (error) { + console.log('error', filename); + } +}; + +const generateThumbnailForVideo = (filename) => { + try { + ffmpeg(nodePath.join(__dirname, '../../uploads', filename)) + .thumbnail({ + timestamps: [0], + filename: '%b.png', + folder: nodePath.join(__dirname, '../../uploads/thumbs/square'), + size: '64x64' + }) + .on('error', (error) => console.error(error.message)); + ffmpeg(nodePath.join(__dirname, '../../uploads', filename)) + .thumbnail({ + timestamps: [0], + filename: '%b.png', + folder: nodePath.join(__dirname, '../../uploads/thumbs'), + size: '150x?' + }) + .on('error', (error) => console.error(error.message)); + console.log('finished', filename); + } catch (error) { + console.log('error', filename); + } +}; + const oldDb = require('knex')({ client: 'sqlite3', connection: { - filename: nodePath.join(__dirname, '..', '..', 'db') + filename: nodePath.join(__dirname, '../../', 'db') }, useNullAsDefault: true }); @@ -19,17 +62,17 @@ const oldDb = require('knex')({ const newDb = require('knex')({ client: 'sqlite3', connection: { - filename: nodePath.join(__dirname, '..', '..', 'database.sqlite') + filename: nodePath.join(__dirname, '../../', 'database.sqlite') }, - postProcessResponse: result => { + postProcessResponse: (result) => { const booleanFields = [ 'enabled', 'enableDownload', 'isAdmin' ]; - const processResponse = row => { - Object.keys(row).forEach(key => { + const processResponse = (row) => { + Object.keys(row).forEach((key) => { if (booleanFields.includes(key)) { if (row[key] === 0) row[key] = false; else if (row[key] === 1) row[key] = true; @@ -38,7 +81,7 @@ const newDb = require('knex')({ return row; }; - if (Array.isArray(result)) return result.map(row => processResponse(row)); + if (Array.isArray(result)) return result.map((row) => processResponse(row)); if (typeof result === 'object') return processResponse(result); return result; }, @@ -49,8 +92,8 @@ const start = async () => { console.log('Starting migration, this may take a few minutes...'); // Because I half assed it console.log('Please do NOT kill the process. Wait for it to finish.'); - await jetpack.removeAsync(nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs')); - await jetpack.dirAsync(nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', 'square')); + await jetpack.removeAsync(nodePath.join(__dirname, '../../uploads/thumbs')); + await jetpack.dirAsync(nodePath.join(__dirname, '../../uploads/thumbs/square')); console.log('Finished deleting old thumbnails to create new ones'); const users = await oldDb.table('users').where('username', '<>', 'root'); @@ -60,7 +103,7 @@ const start = async () => { id: user.id, username: user.username, password: user.password, - enabled: user.enabled == 1 ? true : false, + enabled: user.enabled == 1, isAdmin: false, apiKey: user.token, passwordEditedAt: now, @@ -126,7 +169,7 @@ const start = async () => { }); const filename = file.name; - if (!jetpack.exists(nodePath.join(__dirname, '..', '..', 'uploads', filename))) continue; + if (!jetpack.exists(nodePath.join(__dirname, '../../uploads', filename))) continue; const ext = nodePath.extname(filename).toLowerCase(); const output = `${filename.slice(0, -ext.length)}.webp`; if (imageExtensions.includes(ext)) await generateThumbnailForImage(filename, output); @@ -140,45 +183,4 @@ const start = async () => { process.exit(0); }; -const generateThumbnailForImage = async (filename, output) => { - try { - const file = await jetpack.readAsync(nodePath.join(__dirname, '..', '..', 'uploads', filename), 'buffer'); - await sharp(file) - .resize(64, 64) - .toFormat('webp') - .toFile(nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', 'square', output)); - await sharp(file) - .resize(225, null) - .toFormat('webp') - .toFile(nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', output)); - console.log('finished', filename); - } catch (error) { - console.log('error', filename); - } -}; - -const generateThumbnailForVideo = filename => { - try { - ffmpeg(nodePath.join(__dirname, '..', '..', 'uploads', filename)) - .thumbnail({ - timestamps: [0], - filename: '%b.png', - folder: nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', 'square'), - size: '64x64' - }) - .on('error', error => console.error(error.message)); - ffmpeg(nodePath.join(__dirname, '..', '..', 'uploads', filename)) - .thumbnail({ - timestamps: [0], - filename: '%b.png', - folder: nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs'), - size: '150x?' - }) - .on('error', error => console.error(error.message)); - console.log('finished', filename); - } catch (error) { - console.log('error', filename); - } -}; - start(); diff --git a/src/api/generateThumbs.js b/src/api/generateThumbs.js new file mode 100644 index 0000000..41d3025 --- /dev/null +++ b/src/api/generateThumbs.js @@ -0,0 +1,17 @@ +require('dotenv').config(); + +const fs = require('fs'); +const path = require('path'); + +const ThumbUtil = require('./utils/ThumbUtil'); + +const start = async () => { + const files = fs.readdirSync(path.join(__dirname, '../../', process.env.UPLOAD_FOLDER)); + for (const fileName of files) { + console.log(`Generating thumb for '${fileName}`); + // eslint-disable-next-line no-await-in-loop + await ThumbUtil.generateThumbnails(fileName); + } +}; + +start(); diff --git a/src/api/routes/admin/fileGET.js b/src/api/routes/admin/fileGET.js index 3bb8da4..9605da4 100644 --- a/src/api/routes/admin/fileGET.js +++ b/src/api/routes/admin/fileGET.js @@ -3,7 +3,7 @@ const Util = require('../../utils/Util'); class filesGET extends Route { constructor() { - super('/file/:id', 'get', { adminOnly: true }); + super('/admin/file/:id', 'get', { adminOnly: true }); } async run(req, res, db) { @@ -11,7 +11,10 @@ class filesGET extends Route { 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').where({ id: file.userId }).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 diff --git a/src/api/routes/admin/userGET.js b/src/api/routes/admin/userGET.js index 14a6c92..48c6e9b 100644 --- a/src/api/routes/admin/userGET.js +++ b/src/api/routes/admin/userGET.js @@ -11,7 +11,10 @@ class usersGET extends Route { if (!id) return res.status(400).json({ message: 'Invalid user ID supplied' }); try { - const user = await db.table('users').where({ id }).first(); + 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'); diff --git a/src/api/routes/albums/albumDELETE.js b/src/api/routes/albums/albumDELETE.js index 4e6640e..f9c22e6 100644 --- a/src/api/routes/albums/albumDELETE.js +++ b/src/api/routes/albums/albumDELETE.js @@ -1,5 +1,4 @@ const Route = require('../../structures/Route'); -const Util = require('../../utils/Util'); class albumDELETE extends Route { constructor() { diff --git a/src/api/routes/albums/albumFullGET.js b/src/api/routes/albums/albumFullGET.js index 93b56ce..d25fe15 100644 --- a/src/api/routes/albums/albumFullGET.js +++ b/src/api/routes/albums/albumFullGET.js @@ -10,15 +10,38 @@ class albumGET extends Route { 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(); + const album = await db + .table('albums') + .where({ id, userId: user.id }) + .first(); if (!album) return res.status(404).json({ message: 'Album not found' }); - const files = await db.table('albumsFiles') + let count = 0; + + let files = db + .table('albumsFiles') .where({ albumId: id }) .join('files', 'albumsFiles.fileId', 'files.id') - .select('files.id', 'files.name') + .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); } @@ -26,7 +49,8 @@ class albumGET extends Route { return res.json({ message: 'Successfully retrieved album', name: album.name, - files + files, + count }); } } diff --git a/src/api/routes/albums/albumGET.js b/src/api/routes/albums/albumGET.js index 1bf3630..950a1fd 100644 --- a/src/api/routes/albums/albumGET.js +++ b/src/api/routes/albums/albumGET.js @@ -21,10 +21,11 @@ class albumGET extends Route { const files = await db.table('albumsFiles') .where({ albumId: link.albumId }) .join('files', 'albumsFiles.fileId', 'files.id') - .select('files.name') + .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); } diff --git a/src/api/routes/albums/albumPOST.js b/src/api/routes/albums/albumPOST.js index 0d3a44c..52352a1 100644 --- a/src/api/routes/albums/albumPOST.js +++ b/src/api/routes/albums/albumPOST.js @@ -1,5 +1,5 @@ -const Route = require('../../structures/Route'); const moment = require('moment'); +const Route = require('../../structures/Route'); class albumPOST extends Route { constructor() { @@ -14,18 +14,25 @@ class albumPOST extends Route { /* 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 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(); - await db.table('albums').insert({ + 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' }); + return res.json({ message: 'The album was created successfully', data: insertObj }); } } diff --git a/src/api/routes/albums/albumZipGET.js b/src/api/routes/albums/albumZipGET.js index a6ef6fd..cf1f6f8 100644 --- a/src/api/routes/albums/albumZipGET.js +++ b/src/api/routes/albums/albumZipGET.js @@ -1,8 +1,8 @@ +const path = require('path'); +const jetpack = require('fs-jetpack'); const Route = require('../../structures/Route'); const Util = require('../../utils/Util'); const log = require('../../utils/Log'); -const path = require('path'); -const jetpack = require('fs-jetpack'); class albumGET extends Route { constructor() { @@ -38,7 +38,7 @@ class albumGET extends Route { 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 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. @@ -64,11 +64,11 @@ class albumGET extends Route { /* Get the actual files */ - const fileIds = fileList.map(el => el.fileId); + 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); + const filesToZip = files.map((el) => el.name); try { Util.createZip(filesToZip, album); @@ -76,7 +76,7 @@ class albumGET extends Route { .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 filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`); const fileName = `lolisafe-${identifier}.zip`; return res.download(filePath, fileName); } catch (error) { diff --git a/src/api/routes/albums/albumsGET.js b/src/api/routes/albums/albumsGET.js index 1a7db87..93a23e3 100644 --- a/src/api/routes/albums/albumsGET.js +++ b/src/api/routes/albums/albumsGET.js @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ const Route = require('../../structures/Route'); const Util = require('../../utils/Util'); @@ -12,30 +13,28 @@ class albumsGET extends Route { of the album files for displaying on the dashboard. It's probably useless for anyone consuming the API outside of the lolisafe frontend. */ - const albums = await db.table('albums') + const albums = await db + .table('albums') .where('albums.userId', user.id) - .select('id', 'name', 'editedAt'); + .select('id', 'name', 'createdAt', 'editedAt') + .orderBy('createdAt', 'desc'); for (const album of albums) { - // TODO: Optimize the shit out of this. Ideally a JOIN that grabs all the needed stuff in 1 query instead of 3 - // Fetch the total amount of files each album has. - const fileCount = await db.table('albumsFiles') // eslint-disable-line no-await-in-loop + 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 filesToFetch = await db.table('albumsFiles') // eslint-disable-line no-await-in-loop + const files = await db // eslint-disable-line no-await-in-loop + .table('albumsFiles') + .join('files', { 'files.id': 'albumsFiles.fileId' }) .where('albumId', album.id) - .select('fileId') - .orderBy('id', 'desc') + .select('files.id', 'files.name') + .orderBy('albumsFiles.id', 'desc') .limit(5); - // Fetch the actual files - const files = await db.table('files') // eslint-disable-line no-await-in-loop - .whereIn('id', filesToFetch.map(el => el.fileId)) - .select('id', 'name'); - // Fetch thumbnails and stuff for (let file of files) { file = Util.constructFilePublicLink(file); @@ -58,7 +57,8 @@ class albumsDropdownGET extends Route { } async run(req, res, db, user) { - const albums = await db.table('albums') + const albums = await db + .table('albums') .where('userId', user.id) .select('id', 'name'); return res.json({ diff --git a/src/api/routes/albums/link/linkDELETE.js b/src/api/routes/albums/link/linkDELETE.js index b02d0b4..1af704e 100644 --- a/src/api/routes/albums/link/linkDELETE.js +++ b/src/api/routes/albums/link/linkDELETE.js @@ -1,5 +1,4 @@ const Route = require('../../../structures/Route'); -const { dump } = require('dumper.js'); class linkDELETE extends Route { constructor() { diff --git a/src/api/routes/albums/link/linkEditPOST.js b/src/api/routes/albums/link/linkEditPOST.js index 6776b73..97122a2 100644 --- a/src/api/routes/albums/link/linkEditPOST.js +++ b/src/api/routes/albums/link/linkEditPOST.js @@ -1,5 +1,4 @@ const Route = require('../../../structures/Route'); -const log = require('../../../utils/Log'); class linkEditPOST extends Route { constructor() { @@ -14,17 +13,22 @@ class linkEditPOST extends Route { /* 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' }); + 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 { - await db.table('links') + const updateObj = { + enableDownload: enableDownload || false, + expiresAt // This one should be null if not supplied + }; + await db + .table('links') .where({ identifier }) - .update({ - enableDownload: enableDownload || false, - expiresAt // This one should be null if not supplied - }); - return res.json({ message: 'Editing the link was successful' }); + .update(updateObj); + return res.json({ message: 'Editing the link was successful', data: updateObj }); } catch (error) { return super.error(res, error); } diff --git a/src/api/routes/albums/link/linkPOST.js b/src/api/routes/albums/link/linkPOST.js index 6009922..28e9dfe 100644 --- a/src/api/routes/albums/link/linkPOST.js +++ b/src/api/routes/albums/link/linkPOST.js @@ -14,23 +14,47 @@ class linkPOST extends Route { /* Make sure the album exists */ - const exists = await db.table('albums').where({ id: albumId, userId: user.id }).first(); + 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' }); - if (count[0].count >= parseInt(process.env.MAX_LINKS_PER_ALBUM, 10)) return res.status(400).json({ message: 'Maximum links per album 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' }); - /* - Try to allocate a new identifier on the db - */ - const identifier = await Util.getUniqueAlbumIdentifier(); - if (!identifier) return res.status(500).json({ message: 'There was a problem allocating a link for your album' }); + 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 { - await db.table('links').insert({ + const insertObj = { identifier, userId: user.id, albumId, @@ -38,11 +62,12 @@ class linkPOST extends Route { enableDownload: true, expiresAt: null, views: 0 - }); + }; + await db.table('links').insert(insertObj); return res.json({ message: 'The link was created successfully', - identifier + data: insertObj }); } catch (error) { return super.error(res, error); diff --git a/src/api/routes/auth/loginPOST.js b/src/api/routes/auth/loginPOST.js index 205737a..71867f0 100644 --- a/src/api/routes/auth/loginPOST.js +++ b/src/api/routes/auth/loginPOST.js @@ -1,7 +1,7 @@ -const Route = require('../../structures/Route'); const bcrypt = require('bcrypt'); const moment = require('moment'); const JWT = require('jsonwebtoken'); +const Route = require('../../structures/Route'); class loginPOST extends Route { constructor() { diff --git a/src/api/routes/auth/registerPOST.js b/src/api/routes/auth/registerPOST.js index feeb360..1cf3630 100644 --- a/src/api/routes/auth/registerPOST.js +++ b/src/api/routes/auth/registerPOST.js @@ -1,7 +1,7 @@ -const Route = require('../../structures/Route'); -const log = require('../../utils/Log'); const bcrypt = require('bcrypt'); const moment = require('moment'); +const Route = require('../../structures/Route'); +const log = require('../../utils/Log'); class registerPOST extends Route { constructor() { @@ -9,7 +9,7 @@ class registerPOST extends Route { } 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 (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' }); diff --git a/src/api/routes/files/albumAddPOST.js b/src/api/routes/files/albumAddPOST.js index af39caa..7b8acf7 100644 --- a/src/api/routes/files/albumAddPOST.js +++ b/src/api/routes/files/albumAddPOST.js @@ -24,7 +24,8 @@ class albumAddPOST extends Route { } return res.json({ - message: 'Successfully added file to album' + message: 'Successfully added file to album', + data: { fileId, album: { id: album.id, name: album.name } } }); } } diff --git a/src/api/routes/files/albumDelPOST.js b/src/api/routes/files/albumDelPOST.js index 9a4b87b..8304163 100644 --- a/src/api/routes/files/albumDelPOST.js +++ b/src/api/routes/files/albumDelPOST.js @@ -25,7 +25,8 @@ class albumDelPOST extends Route { } return res.json({ - message: 'Successfully removed file from album' + message: 'Successfully removed file from album', + data: { fileId, album: { id: album.id, name: album.name } } }); } } 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 index 7f1190c..90aa654 100644 --- a/src/api/routes/files/filesAlbumsGET.js +++ b/src/api/routes/files/filesAlbumsGET.js @@ -18,7 +18,7 @@ class filesGET extends Route { .select('albumId'); if (albumFiles.length) { - albumFiles = albumFiles.map(a => a.albumId); + albumFiles = albumFiles.map((a) => a.albumId); albums = await db.table('albums') .whereIn('id', albumFiles) .select('id', 'name'); diff --git a/src/api/routes/files/filesGET.js b/src/api/routes/files/filesGET.js index f1a3a26..9e90633 100644 --- a/src/api/routes/files/filesGET.js +++ b/src/api/routes/files/filesGET.js @@ -7,10 +7,26 @@ class filesGET extends Route { } async run(req, res, db, user) { - // Get all the files from the user - const files = await db.table('files') - .where('userId', user.id) - .orderBy('id', 'desc'); + 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) { @@ -19,7 +35,8 @@ class filesGET extends Route { return res.json({ message: 'Successfully retrieved files', - files + files, + count }); } } 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 index 25467ab..2bbfa07 100644 --- a/src/api/routes/files/tagAddPOST.js +++ b/src/api/routes/files/tagAddPOST.js @@ -7,24 +7,29 @@ class tagAddPOST extends Route { 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' }); + + 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.' }); - tagNames.forEach(async tag => { - try { - await db.table('fileTags').insert({ fileId, tag }); - } catch (error) { - return super.error(res, error); - } - }); + // 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 file to album' + message: 'Successfully added tag to file', + data: { fileId, tag } }); + // eslint-disable-next-line consistent-return } } 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 index b653066..bc91a7e 100644 --- a/src/api/routes/service/configGET.js +++ b/src/api/routes/service/configGET.js @@ -15,10 +15,10 @@ class configGET extends Route { 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' ? true : false, - generateZips: process.env.GENERATE_ZIPS == 'true' ? true : false, - publicMode: process.env.PUBLIC_MODE == 'true' ? true : false, - enableAccounts: process.env.USER_ACCOUNTS == 'true' ? true : false + generateThumbnails: process.env.GENERATE_THUMBNAILS === 'true', + generateZips: process.env.GENERATE_ZIPS === 'true', + publicMode: process.env.PUBLIC_MODE === 'true', + enableAccounts: process.env.USER_ACCOUNTS === 'true' } }); } diff --git a/src/api/routes/tags/tagDELETE.js b/src/api/routes/tags/tagDELETE.js index c03ca64..cf74029 100644 --- a/src/api/routes/tags/tagDELETE.js +++ b/src/api/routes/tags/tagDELETE.js @@ -27,7 +27,7 @@ class tagDELETE extends Route { Delete the tag */ await db.table('tags').where({ id }).delete(); - return res.json({ message: 'The tag was deleted successfully' }); + return res.json({ message: 'The tag was deleted successfully', data: tag }); } catch (error) { return super.error(res, error); } diff --git a/src/api/routes/tags/tagPOST.js b/src/api/routes/tags/tagPOST.js index b6ec395..89b296d 100644 --- a/src/api/routes/tags/tagPOST.js +++ b/src/api/routes/tags/tagPOST.js @@ -1,5 +1,5 @@ -const Route = require('../../structures/Route'); const moment = require('moment'); +const Route = require('../../structures/Route'); class tagPOST extends Route { constructor() { @@ -18,14 +18,18 @@ class tagPOST extends Route { if (tag) return res.status(401).json({ message: 'There\'s already a tag with that name' }); const now = moment.utc().toDate(); - await db.table('tags').insert({ + 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' }); + return res.json({ message: 'The tag was created successfully', data: insertObj }); } } diff --git a/src/api/routes/tags/tagsGET.js b/src/api/routes/tags/tagsGET.js index 871148e..329d789 100644 --- a/src/api/routes/tags/tagsGET.js +++ b/src/api/routes/tags/tagsGET.js @@ -1,5 +1,4 @@ const Route = require('../../structures/Route'); -const Util = require('../../utils/Util'); class tagsGET extends Route { constructor() { diff --git a/src/api/routes/uploads/chunksPOST.js b/src/api/routes/uploads/chunksPOST.js index 013c0d6..061cfb0 100644 --- a/src/api/routes/uploads/chunksPOST.js +++ b/src/api/routes/uploads/chunksPOST.js @@ -1,8 +1,8 @@ -const Route = require('../../structures/Route'); const path = require('path'); -const Util = require('../../utils/Util'); const jetpack = require('fs-jetpack'); const randomstring = require('randomstring'); +const Util = require('../../utils/Util'); +const Route = require('../../structures/Route'); class uploadPOST extends Route { constructor() { @@ -12,7 +12,7 @@ class uploadPOST extends Route { }); } - async run(req, res, db) { + async run(req, res) { const filename = Util.getUniqueFilename(randomstring.generate(32)); // console.log('Files', req.body.files); const info = { @@ -21,24 +21,18 @@ class uploadPOST extends Route { }; for (const chunk of req.body.files) { - const { uuid, count } = chunk; + 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(); @@ -49,10 +43,7 @@ class uploadPOST extends Route { for (let i = 0; i < chunkDir.length; i++) { const dir = path.join(__dirname, - '..', - '..', - '..', - '..', + '../../../../', process.env.UPLOAD_FOLDER, 'chunks', uuid, diff --git a/src/api/routes/uploads/uploadPOST.js b/src/api/routes/uploads/uploadPOST.js index 4b84da6..567862a 100644 --- a/src/api/routes/uploads/uploadPOST.js +++ b/src/api/routes/uploads/uploadPOST.js @@ -1,17 +1,18 @@ -const Route = require('../../structures/Route'); const path = require('path'); -const Util = require('../../utils/Util'); 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 + 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.`)); @@ -19,22 +20,21 @@ const upload = multer({ return cb(new Error(`${path.extname(file.originalname).toLowerCase()} is a blacklisted extension.`)); } */ - return cb(null, true); - } + 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: If source is a video, generate a thumb of the first frame and save the video length to the file? - Another possible solution would be to play a gif on hover that grabs a few chunks like youtube. 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 - - Addendum to this: 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. + 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 { @@ -47,7 +47,7 @@ class uploadPOST extends Route { 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' }); + 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' }); @@ -56,12 +56,13 @@ class uploadPOST extends Route { 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 => { + 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]; @@ -83,10 +84,7 @@ class uploadPOST extends Route { if (remappedKeys && remappedKeys.uuid) { const chunkOutput = path.join(__dirname, - '..', - '..', - '..', - '..', + '../../../../', process.env.UPLOAD_FOLDER, 'chunks', remappedKeys.uuid, @@ -94,10 +92,7 @@ class uploadPOST extends Route { await jetpack.writeAsync(chunkOutput, file.buffer); } else { const output = path.join(__dirname, - '..', - '..', - '..', - '..', + '../../../../', process.env.UPLOAD_FOLDER, filename); await jetpack.writeAsync(output, file.buffer); @@ -147,7 +142,7 @@ class uploadPOST extends Route { async checkIfFileExists(db, user, hash) { const exists = await db.table('files') - .where(function() { // eslint-disable-line func-names + .where(function () { // eslint-disable-line func-names if (user) this.where('userId', user.id); else this.whereNull('userId'); }) @@ -222,6 +217,7 @@ class uploadPOST extends Route { } return body; } + return keys; } } diff --git a/src/api/routes/user/apiKey.js b/src/api/routes/user/apiKey.js index a87d98d..653c56a 100644 --- a/src/api/routes/user/apiKey.js +++ b/src/api/routes/user/apiKey.js @@ -1,7 +1,7 @@ -const Route = require('../../structures/Route'); const randomstring = require('randomstring'); const moment = require('moment'); const { dump } = require('dumper.js'); +const Route = require('../../structures/Route'); class apiKeyPOST extends Route { constructor() { diff --git a/src/api/routes/user/changePasswordPOST.js b/src/api/routes/user/changePasswordPOST.js index 9cd621e..82bce40 100644 --- a/src/api/routes/user/changePasswordPOST.js +++ b/src/api/routes/user/changePasswordPOST.js @@ -1,7 +1,7 @@ -const Route = require('../../structures/Route'); -const log = require('../../utils/Log'); const bcrypt = require('bcrypt'); const moment = require('moment'); +const Route = require('../../structures/Route'); +const log = require('../../utils/Log'); class changePasswordPOST extends Route { constructor() { diff --git a/src/api/routes/user/userGET.js b/src/api/routes/user/userGET.js index fe46fd4..7929aac 100644 --- a/src/api/routes/user/userGET.js +++ b/src/api/routes/user/userGET.js @@ -11,7 +11,8 @@ class usersGET extends Route { user: { id: user.id, username: user.username, - isAdmin: user.isAdmin + isAdmin: user.isAdmin, + apiKey: user.apiKey } }); } diff --git a/src/api/structures/Route.js b/src/api/structures/Route.js index 8956c24..74589c5 100644 --- a/src/api/structures/Route.js +++ b/src/api/structures/Route.js @@ -7,23 +7,19 @@ const db = require('knex')({ user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, - filename: nodePath.join(__dirname, '..', '..', '..', 'database.sqlite') + filename: nodePath.join(__dirname, '../../../database.sqlite') }, - postProcessResponse: result => { + postProcessResponse: (result) => { /* Fun fact: Depending on the database used by the user and given that I don't want to force a specific database for everyone because of the nature of this project, some things like different data types for booleans need to be considered like in the implementation below where sqlite returns 1 and 0 instead of true and false. */ - const booleanFields = [ - 'enabled', - 'enableDownload', - 'isAdmin' - ]; + const booleanFields = ['enabled', 'enableDownload', 'isAdmin']; - const processResponse = row => { - Object.keys(row).forEach(key => { + const processResponse = (row) => { + Object.keys(row).forEach((key) => { if (booleanFields.includes(key)) { if (row[key] === 0) row[key] = false; else if (row[key] === 1) row[key] = true; @@ -32,11 +28,11 @@ const db = require('knex')({ return row; }; - if (Array.isArray(result)) return result.map(row => processResponse(row)); + if (Array.isArray(result)) return result.map((row) => processResponse(row)); if (typeof result === 'object') return processResponse(result); return result; }, - useNullAsDefault: process.env.DB_CLIENT === 'sqlite3' ? true : false + useNullAsDefault: process.env.DB_CLIENT === 'sqlite3' }); const moment = require('moment'); const log = require('../utils/Log'); @@ -52,11 +48,15 @@ class Route { } async authorize(req, res) { - const banned = await db.table('bans').where({ ip: req.ip }).first(); + const banned = await db + .table('bans') + .where({ ip: req.ip }) + .first(); if (banned) return res.status(401).json({ message: 'This IP has been banned from using the service.' }); if (this.options.bypassAuth) return this.run(req, res, db); - // The only reason I call it token here and not Api Key is to be backwards compatible with the uploader and sharex + // The only reason I call it token here and not Api Key is to be backwards compatible + // with the uploader and sharex // Small price to pay. if (req.headers.token) return this.authorizeApiKey(req, res, req.headers.token); if (!req.headers.authorization) return res.status(401).json({ message: 'No authorization header provided' }); @@ -72,11 +72,16 @@ class Route { const id = decoded ? decoded.sub : ''; const iat = decoded ? decoded.iat : ''; - const user = await db.table('users').where({ id }).first(); + const user = await db + .table('users') + .where({ id }) + .first(); if (!user) return res.status(401).json({ message: 'Invalid authorization' }); - if (iat && iat < moment(user.passwordEditedAt).format('x')) return res.status(401).json({ message: 'Token expired' }); + if (iat && iat < moment(user.passwordEditedAt).format('x')) { + return res.status(401).json({ message: 'Token expired' }); + } if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' }); - if (this.options.adminOnly && !user.isAdmin) return res.status(401).json({ message: 'Invalid authorization' }); + if (this.options.adminOnly && !user.isAdmin) { return res.status(401).json({ message: 'Invalid authorization' }); } return this.run(req, res, db, user); }); @@ -84,16 +89,17 @@ class Route { async authorizeApiKey(req, res, apiKey) { if (!this.options.canApiKey) return res.status(401).json({ message: 'Api Key not allowed for this resource' }); - const user = await db.table('users').where({ apiKey }).first(); + const user = await db + .table('users') + .where({ apiKey }) + .first(); if (!user) return res.status(401).json({ message: 'Invalid authorization' }); if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' }); return this.run(req, res, db, user); } - run(req, res, db) { // eslint-disable-line no-unused-vars - return; - } + run() {} error(res, error) { log.error(error); diff --git a/src/api/structures/Server.js b/src/api/structures/Server.js index 2039ed5..83b2880 100644 --- a/src/api/structures/Server.js +++ b/src/api/structures/Server.js @@ -1,6 +1,5 @@ require('dotenv').config(); -const log = require('../utils/Log'); const express = require('express'); const helmet = require('helmet'); const cors = require('cors'); @@ -8,7 +7,11 @@ const RateLimit = require('express-rate-limit'); const bodyParser = require('body-parser'); const jetpack = require('fs-jetpack'); const path = require('path'); +const morgan = require('morgan'); +const log = require('../utils/Log'); +const ThumbUtil = require('../utils/ThumbUtil'); +// eslint-disable-next-line no-unused-vars const rateLimiter = new RateLimit({ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW, 10), max: parseInt(process.env.RATE_LIMIT_MAX, 10), @@ -32,22 +35,48 @@ class Server { }); this.server.use(bodyParser.urlencoded({ extended: true })); this.server.use(bodyParser.json()); + if (process.env.NODE_ENV !== 'production') { + this.server.use(morgan('combined', { + skip(req) { + let ext = req.path.split('.').pop(); + if (ext) { ext = `.${ext.toLowerCase()}`; } + + if ( + ThumbUtil.imageExtensions.indexOf(ext) > -1 + || ThumbUtil.videoExtensions.indexOf(ext) > -1 + || req.path.indexOf('_nuxt') > -1 + || req.path.indexOf('favicon.ico') > -1 + ) { + return true; + } + return false; + }, + 'stream': { + write(str) { log.debug(str); } + } + })); + } // this.server.use(rateLimiter); // Serve the uploads - this.server.use(express.static(path.join(__dirname, '..', '..', '..', 'uploads'))); - this.routesFolder = path.join(__dirname, '..', 'routes'); + this.server.use(express.static(path.join(__dirname, '../../../uploads'))); + this.routesFolder = path.join(__dirname, '../routes'); } registerAllTheRoutes() { - jetpack.find(this.routesFolder, { matching: '*.js' }).forEach(routeFile => { - const RouteClass = require(path.join('..', '..', '..', routeFile)); + jetpack.find(this.routesFolder, { matching: '*.js' }).forEach((routeFile) => { + // eslint-disable-next-line import/no-dynamic-require, global-require + const RouteClass = require(path.join('../../../', routeFile)); let routes = [RouteClass]; if (Array.isArray(RouteClass)) routes = RouteClass; for (const File of routes) { - const route = new File(); - this.server[route.method](process.env.ROUTE_PREFIX + route.path, route.authorize.bind(route)); - log.info(`Found route ${route.method.toUpperCase()} ${process.env.ROUTE_PREFIX}${route.path}`); + try { + const route = new File(); + this.server[route.method](process.env.ROUTE_PREFIX + route.path, route.authorize.bind(route)); + log.info(`Found route ${route.method.toUpperCase()} ${process.env.ROUTE_PREFIX}${route.path}`); + } catch (e) { + log.error(`Failed loading route from file ${routeFile} with error: ${e.message}`); + } } }); } @@ -55,7 +84,7 @@ class Server { serveNuxt() { // Serve the frontend if we are in production mode if (process.env.NODE_ENV === 'production') { - this.server.use(express.static(path.join(__dirname, '..', '..', '..', 'dist'))); + this.server.use(express.static(path.join(__dirname, '../../../dist'))); } /* @@ -66,7 +95,7 @@ class Server { */ this.server.all('*', (_req, res) => { try { - res.sendFile(path.join(__dirname, '..', '..', '..', 'dist', 'index.html')); + res.sendFile(path.join(__dirname, '../../../dist/index.html')); } catch (error) { res.json({ success: false, message: 'Something went wrong' }); } diff --git a/src/api/utils/Log.js b/src/api/utils/Log.js index 6753f9e..9a5efc9 100644 --- a/src/api/utils/Log.js +++ b/src/api/utils/Log.js @@ -3,30 +3,29 @@ const { dump } = require('dumper.js'); class Log { static info(args) { - if (this.checkIfArrayOrObject(args)) dump(args); + if (Log.checkIfArrayOrObject(args)) dump(args); else console.log(args); // eslint-disable-line no-console } static success(args) { - if (this.checkIfArrayOrObject(args)) dump(args); + if (Log.checkIfArrayOrObject(args)) dump(args); else console.log(chalk.green(args)); // eslint-disable-line no-console } static warn(args) { - if (this.checkIfArrayOrObject(args)) dump(args); + if (Log.checkIfArrayOrObject(args)) dump(args); else console.log(chalk.yellow(args)); // eslint-disable-line no-console } static error(args) { - if (this.checkIfArrayOrObject(args)) dump(args); + if (Log.checkIfArrayOrObject(args)) dump(args); else console.log(chalk.red(args)); // eslint-disable-line no-console } - /* - static dump(args) { - dump(args); + static debug(args) { + if (Log.checkIfArrayOrObject(args)) dump(args); + else console.log(chalk.gray(args)); // eslint-disable-line no-console } - */ static checkIfArrayOrObject(thing) { if (typeof thing === typeof [] || typeof thing === typeof {}) return true; diff --git a/src/api/utils/QueryHelper.js b/src/api/utils/QueryHelper.js new file mode 100644 index 0000000..7fabd06 --- /dev/null +++ b/src/api/utils/QueryHelper.js @@ -0,0 +1,200 @@ +const chrono = require('chrono-node'); + +class QueryHelper { + static parsers = { + before: (val) => QueryHelper.parseChronoList(val), + after: (val) => QueryHelper.parseChronoList(val), + tag: (val) => QueryHelper.sanitizeTags(val) + }; + + static requirementHandlers = { + album: (knex) => knex + .join('albumsFiles', 'files.id', '=', 'albumsFiles.fileId') + .join('albums', 'albumsFiles.albumId', '=', 'album.id'), + tag: (knex) => knex + .join('fileTags', 'files.id', '=', 'fileTags.fileId') + .join('tags', 'fileTags.tagId', '=', 'tags.id') + } + + static fieldToSQLMapping = { + album: 'albums.name', + tag: 'tags.name', + before: 'files.createdAt', + after: 'files.createdAt' + } + + static handlers = { + album({ db, knex }, list) { + return QueryHelper.generateInclusionForAlbums(db, knex, list); + }, + tag({ db, knex }, list) { + list = QueryHelper.parsers.tag(list); + return QueryHelper.generateInclusionForTags(db, knex, list); + }, + before({ knex }, list) { + list = QueryHelper.parsers.before(list); + return QueryHelper.generateBefore(knex, 'before', list); + }, + after({ knex }, list) { + list = QueryHelper.parsers.after(list); + return QueryHelper.generateAfter(knex, 'after', list); + }, + file({ knex }, list) { + return QueryHelper.generateLike(knex, 'name', list); + }, + exclude({ db, knex }, dict) { + for (const [key, value] of Object.entries(dict)) { + if (key === 'album') { + knex = QueryHelper.generateExclusionForAlbums(db, knex, value); + } + if (key === 'tag') { + const parsed = QueryHelper.parsers.tag(value); + knex = QueryHelper.generateExclusionForTags(db, knex, parsed); + } + } + return knex; + } + } + + static verify(field, list) { + if (!Array.isArray(list)) { + throw new Error(`Expected Array got ${typeof list}`); + } + if (typeof field !== 'string') { + throw new Error(`Expected string got ${typeof field}`); + } + return true; + } + + static getMapping(field) { + if (!QueryHelper.fieldToSQLMapping[field]) { + throw new Error(`No SQL mapping for ${field} field found`); + } + + return QueryHelper.fieldToSQLMapping[field]; + } + + static generateIn(knex, field, list) { + QueryHelper.verify(field, list); + return knex.whereIn(QueryHelper.getMapping(field), list); + } + + static generateNotIn(knex, field, list) { + QueryHelper.verify(field, list); + return knex.whereNotExists(QueryHelper.getMapping(field), list); + } + + static generateBefore(knex, field, list) { + QueryHelper.verify(field, list); + } + + static generateAfter(knex, field, list) { + QueryHelper.verify(field, list); + } + + static parseChronoList(list) { + return list.map((e) => chrono.parse(e)); + } + + static sanitizeTags(list) { + return list.map((e) => e.replace(/\s/g, '_')); + } + + static generateInclusionForTags(db, knex, list) { + const subQ = db.table('fileTags') + .select('fileTags.fileId') + .join('tags', 'fileTags.tagId', '=', 'tags.id') + .where('fileTags.fileId', db.ref('files.id')) + .whereIn('tags.name', list) + .groupBy('fileTags.fileId') + .havingRaw('count(distinct tags.name) = ?', [list.length]); + + return knex.whereIn('files.id', subQ); + } + + static generateInclusionForAlbums(db, knex, list) { + const subQ = db.table('albumsFiles') + .select('albumsFiles.fileId') + .join('albums', 'albumsFiles.albumId', '=', 'albums.id') + .where('albumsFiles.fileId', db.ref('files.id')) + .whereIn('albums.name', list) + .groupBy('albumsFiles.fileId') + .havingRaw('count(distinct albums.name) = ?', [list.length]); + + return knex.whereIn('files.id', subQ); + } + + static generateExclusionForTags(db, knex, list) { + const subQ = db.table('fileTags') + .select('fileTags.fileId') + .join('tags', 'fileTags.tagId', '=', 'tags.id') + .where('fileTags.fileId', db.ref('files.id')) + .whereIn('tags.name', list); + + return knex.whereNotIn('files.id', subQ); + } + + static generateExclusionForAlbums(db, knex, list) { + const subQ = db.table('albumsFiles') + .select('albumsFiles.fileId') + .join('albums', 'albumsFiles.albumId', '=', 'albums.id') + .where('albumsFiles.fileId', db.ref('files.id')) + .whereIn('albums.name', list); + + return knex.whereNotIn('files.id', subQ); + } + + static generateLike(knex, field, list) { + for (const str of list) { + knex = knex.where(field, 'like', `${str}%`); + } + + return knex; + } + + static loadRequirements(knex, queryObject) { + // sanity check so we don't accidentally require the same thing twice + const loadedRequirements = []; + + for (const key of Object.keys(queryObject)) { + if (QueryHelper.requirementHandlers[key] && loadedRequirements.indexOf(key) === -1) { + knex = QueryHelper.requirementHandlers[key](knex); + loadedRequirements.push(key); + } + } + + return knex; + } + + static mergeTextWithTags(queryObject) { + if (queryObject.text) { + let { text } = queryObject; + if (!Array.isArray(text)) { text = [text]; } + + queryObject.tag = [...(queryObject.tag || []), ...text]; + } + + if (queryObject.exclude && queryObject.exclude.text) { + let { text } = queryObject.exclude; + if (!Array.isArray(text)) { text = [text]; } + + queryObject.exclude.tag = [...(queryObject.exclude.tag || []), ...text]; + } + + return queryObject; + } + + static processQuery(db, knex, queryObject) { + queryObject = QueryHelper.mergeTextWithTags(queryObject); + // knex = QueryHelper.loadRequirements(knex, queryObject); + for (const [key, value] of Object.entries(queryObject)) { + if (QueryHelper.handlers[key]) { + knex = QueryHelper.handlers[key]({ db, knex }, value); + } + } + + return knex; + } +} + +module.exports = QueryHelper; diff --git a/src/api/utils/ThumbUtil.js b/src/api/utils/ThumbUtil.js new file mode 100644 index 0000000..10a7cd9 --- /dev/null +++ b/src/api/utils/ThumbUtil.js @@ -0,0 +1,108 @@ +const jetpack = require('fs-jetpack'); +const path = require('path'); +const sharp = require('sharp'); +const ffmpeg = require('fluent-ffmpeg'); +const previewUtil = require('./videoPreview/FragmentPreview'); + +const log = require('./Log'); + +class ThumbUtil { + static imageExtensions = ['.jpg', '.jpeg', '.gif', '.png', '.webp']; + + static videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov']; + + static thumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs'); + + static squareThumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'square'); + + static videoPreviewPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'preview'); + + static generateThumbnails(filename) { + const ext = path.extname(filename).toLowerCase(); + const output = `${filename.slice(0, -ext.length)}.png`; + const previewOutput = `${filename.slice(0, -ext.length)}.webm`; + + // eslint-disable-next-line max-len + if (ThumbUtil.imageExtensions.includes(ext)) return ThumbUtil.generateThumbnailForImage(filename, output); + // eslint-disable-next-line max-len + if (ThumbUtil.videoExtensions.includes(ext)) return ThumbUtil.generateThumbnailForVideo(filename, previewOutput); + return null; + } + + static async generateThumbnailForImage(filename, output) { + const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename); + + const file = await jetpack.readAsync(filePath, 'buffer'); + await sharp(file) + .resize(64, 64) + .toFormat('png') + .toFile(path.join(ThumbUtil.squareThumbPath, output)); + await sharp(file) + .resize(225, null) + .toFormat('png') + .toFile(path.join(ThumbUtil.thumbPath, output)); + } + + static async generateThumbnailForVideo(filename, output) { + const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename); + + ffmpeg(filePath) + .thumbnail({ + timestamps: [0], + filename: '%b.png', + folder: ThumbUtil.squareThumbPath, + size: '64x64' + }) + .on('error', (error) => log.error(error.message)); + + ffmpeg(filePath) + .thumbnail({ + timestamps: [0], + filename: '%b.png', + folder: ThumbUtil.thumbPath, + size: '150x?' + }) + .on('error', (error) => log.error(error.message)); + + try { + await previewUtil({ + input: filePath, + width: 150, + output: path.join(ThumbUtil.videoPreviewPath, output), + log: log.debug + }); + } catch (e) { + log.error(e); + } + } + + static getFileThumbnail(filename) { + if (!filename) return null; + const ext = path.extname(filename).toLowerCase(); + + const isImage = ThumbUtil.imageExtensions.includes(ext); + const isVideo = ThumbUtil.videoExtensions.includes(ext); + + if (isImage) return { thumb: `${filename.slice(0, -ext.length)}.webp` }; + if (isVideo) { + return { + thumb: `${filename.slice(0, -ext.length)}.png`, + preview: `${filename.slice(0, -ext.length)}.webm` + }; + } + + return null; + } + + static async removeThumbs({ thumb, preview }) { + if (thumb) { + await jetpack.removeAsync(path.join(ThumbUtil.thumbPath, thumb)); + await jetpack.removeAsync(path.join(ThumbUtil.squareThumbPath, thumb)); + } + if (preview) { + await jetpack.removeAsync(path.join(ThumbUtil.videoPreviewPath, preview)); + } + } +} + +module.exports = ThumbUtil; diff --git a/src/api/utils/Util.js b/src/api/utils/Util.js index a4af81e..4279b6f 100644 --- a/src/api/utils/Util.js +++ b/src/api/utils/Util.js @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ const jetpack = require('fs-jetpack'); const randomstring = require('randomstring'); const path = require('path'); @@ -9,23 +10,23 @@ const db = require('knex')({ user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, - filename: path.join(__dirname, '..', '..', '..', 'database.sqlite') + filename: path.join(__dirname, '../../../database.sqlite') }, - useNullAsDefault: process.env.DB_CLIENT === 'sqlite' ? true : false + useNullAsDefault: process.env.DB_CLIENT === 'sqlite' }); const moment = require('moment'); -const log = require('../utils/Log'); const crypto = require('crypto'); -const sharp = require('sharp'); -const ffmpeg = require('fluent-ffmpeg'); const Zip = require('adm-zip'); const uuidv4 = require('uuid/v4'); -const imageExtensions = ['.jpg', '.jpeg', '.bmp', '.gif', '.png', '.webp']; -const videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov']; +const log = require('./Log'); +const ThumbUtil = require('./ThumbUtil'); + const blockedExtensions = process.env.BLOCKED_EXTENSIONS.split(','); class Util { + static uploadPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER); + static uuid() { return uuidv4(); } @@ -34,63 +35,17 @@ class Util { return blockedExtensions.includes(extension); } - static generateThumbnails(filename) { - const ext = path.extname(filename).toLowerCase(); - const output = `${filename.slice(0, -ext.length)}.webp`; - if (imageExtensions.includes(ext)) return this.generateThumbnailForImage(filename, output); - if (videoExtensions.includes(ext)) return this.generateThumbnailForVideo(filename); - return null; - } - - static async generateThumbnailForImage(filename, output) { - const file = await jetpack.readAsync(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename), 'buffer'); - await sharp(file) - .resize(64, 64) - .toFormat('webp') - .toFile(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', 'square', output)); - await sharp(file) - .resize(225, null) - .toFormat('webp') - .toFile(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', output)); - } - - static generateThumbnailForVideo(filename) { - ffmpeg(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename)) - .thumbnail({ - timestamps: [0], - filename: '%b.png', - folder: path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', 'square'), - size: '64x64' - }) - .on('error', error => log.error(error.message)); - ffmpeg(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename)) - .thumbnail({ - timestamps: [0], - filename: '%b.png', - folder: path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs'), - size: '150x?' - }) - .on('error', error => log.error(error.message)); - } - - static getFileThumbnail(filename) { - if (!filename) return null; - const ext = path.extname(filename).toLowerCase(); - const extension = imageExtensions.includes(ext) ? 'webp' : videoExtensions.includes(ext) ? 'png' : null; - if (!extension) return null; - return `${filename.slice(0, -ext.length)}.${extension}`; - } - static constructFilePublicLink(file) { /* TODO: This wont work without a reverse proxy serving both the site and the API under the same domain. Pls fix. */ file.url = `${process.env.DOMAIN}/${file.name}`; - const thumb = this.getFileThumbnail(file.name); + const { thumb, preview } = ThumbUtil.getFileThumbnail(file.name) || {}; if (thumb) { file.thumb = `${process.env.DOMAIN}/thumbs/${thumb}`; file.thumbSquare = `${process.env.DOMAIN}/thumbs/square/${thumb}`; + file.preview = preview && `${process.env.DOMAIN}/thumbs/preview/${preview}`; } return file; } @@ -103,7 +58,7 @@ class Util { }) + path.extname(name).toLowerCase(); // TODO: Change this to look for the file in the db instead of in the filesystem - const exists = jetpack.exists(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename)); + const exists = jetpack.exists(path.join(Util.uploadPath, filename)); if (!exists) return filename; if (i < 5) return retry(i + 1); log.error('Couldnt allocate identifier for file'); @@ -118,7 +73,10 @@ class Util { length: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10), capitalization: 'lowercase' }); - const exists = await db.table('links').where({ identifier }).first(); + const exists = await db + .table('links') + .where({ identifier }) + .first(); if (!exists) return identifier; /* It's funny but if you do i++ the asignment never gets done resulting in an infinite loop @@ -131,7 +89,7 @@ class Util { } static async getFileHash(filename) { - const file = await jetpack.readAsync(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename), 'buffer'); + const file = await jetpack.readAsync(path.join(Util.uploadPath, filename), 'buffer'); if (!file) { log.error(`There was an error reading the file < ${filename} > for hashing`); return null; @@ -143,7 +101,10 @@ class Util { } static generateFileHash(data) { - const hash = crypto.createHash('md5').update(data).digest('hex'); + const hash = crypto + .createHash('md5') + .update(data) + .digest('hex'); return hash; } @@ -152,18 +113,16 @@ class Util { } static async deleteFile(filename, deleteFromDB = false) { - const thumbName = this.getFileThumbnail(filename); + const thumbName = ThumbUtil.getFileThumbnail(filename); try { - await jetpack.removeAsync(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename)); - if (thumbName) { - const thumb = path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', thumbName); - const thumbSquare = path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', 'square', thumbName); - if (await jetpack.existsAsync(thumb)) jetpack.removeAsync(thumb); - if (await jetpack.existsAsync(thumbSquare)) jetpack.removeAsync(thumbSquare); - } + await jetpack.removeAsync(path.join(Util.uploadPath, filename)); + await ThumbUtil.removeThumbs(thumbName); if (deleteFromDB) { - await db.table('files').where('name', filename).delete(); + await db + .table('files') + .where('name', filename) + .delete(); } } catch (error) { log.error(`There was an error removing the file < ${filename} >`); @@ -175,10 +134,13 @@ class Util { try { const fileAlbums = await db.table('albumsFiles').where({ albumId: id }); for (const fileAlbum of fileAlbums) { - const file = await db.table('files') + const file = await db + .table('files') .where({ id: fileAlbum.fileId }) .first(); + if (!file) continue; + await this.deleteFile(file.name, true); } } catch (error) { @@ -201,7 +163,8 @@ class Util { try { const fileTags = await db.table('fileTags').where({ tagId: id }); for (const fileTag of fileTags) { - const file = await db.table('files') + const file = await db + .table('files') .where({ id: fileTag.fileId }) .first(); if (!file) continue; @@ -231,7 +194,10 @@ class Util { const id = decoded ? decoded.sub : ''; const iat = decoded ? decoded.iat : ''; - const user = await db.table('users').where({ id }).first(); + const user = await db + .table('users') + .where({ id }) + .first(); if (!user || !user.enabled) return false; if (iat && iat < moment(user.passwordEditedAt).format('x')) return false; @@ -243,13 +209,23 @@ class Util { try { const zip = new Zip(); for (const file of files) { - zip.addLocalFile(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, file)); + zip.addLocalFile(path.join(Util.uploadPath, file)); } - zip.writeZip(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`)); + zip.writeZip( + path.join( + __dirname, + '../../../', + process.env.UPLOAD_FOLDER, + 'zips', + `${album.userId}-${album.id}.zip` + ) + ); } catch (error) { log.error(error); } } + + static generateThumbnails = ThumbUtil.generateThumbnails; } module.exports = Util; diff --git a/src/api/utils/videoPreview/FragmentPreview.js b/src/api/utils/videoPreview/FragmentPreview.js new file mode 100644 index 0000000..4f681fa --- /dev/null +++ b/src/api/utils/videoPreview/FragmentPreview.js @@ -0,0 +1,88 @@ +/* eslint-disable no-bitwise */ +const ffmpeg = require('fluent-ffmpeg'); +const probe = require('ffmpeg-probe'); + +const noop = () => {}; + +const getRandomInt = (min, max) => { + const minInt = Math.ceil(min); + const maxInt = Math.floor(max); + + // eslint-disable-next-line no-mixed-operators + return Math.floor(Math.random() * (maxInt - minInt + 1) + minInt); +}; + +const getStartTime = (vDuration, fDuration, ignoreBeforePercent, ignoreAfterPercent) => { + // by subtracting the fragment duration we can be sure that the resulting + // start time + fragment duration will be less than the video duration + const safeVDuration = vDuration - fDuration; + + // if the fragment duration is longer than the video duration + if (safeVDuration <= 0) { + return 0; + } + + return getRandomInt(ignoreBeforePercent * safeVDuration, ignoreAfterPercent * safeVDuration); +}; + +module.exports = async (opts) => { + const { + log = noop, + + // general output options + quality = 2, + width, + height, + input, + output, + + fragmentDurationSecond = 3, + ignoreBeforePercent = 0.25, + ignoreAfterPercent = 0.75 + } = opts; + + const info = await probe(input); + + let { duration } = info.format; + duration = parseInt(duration, 10); + + const startTime = getStartTime(duration, fragmentDurationSecond, ignoreBeforePercent, ignoreAfterPercent); + + const result = { startTime, duration }; + + await new Promise((resolve, reject) => { + let scale = null; + + if (width && height) { + result.width = width | 0; + result.height = height | 0; + scale = `scale=${width}:${height}`; + } else if (width) { + result.width = width | 0; + result.height = ((info.height * width) / info.width) | 0; + scale = `scale=${width}:-1`; + } else if (height) { + result.height = height | 0; + result.width = ((info.width * height) / info.height) | 0; + scale = `scale=-1:${height}`; + } else { + result.width = info.width; + result.height = info.height; + } + + return ffmpeg() + .input(input) + .inputOptions([`-ss ${startTime}`]) + .outputOptions(['-vsync', 'vfr']) + .outputOptions(['-q:v', quality, '-vf', scale]) + .outputOptions([`-t ${fragmentDurationSecond}`]) + .noAudio() + .output(output) + .on('start', (cmd) => log && log({ cmd })) + .on('end', resolve) + .on('error', reject) + .run(); + }); + + return result; +}; diff --git a/src/api/utils/videoPreview/FrameIntervalPreview.js b/src/api/utils/videoPreview/FrameIntervalPreview.js new file mode 100644 index 0000000..8bb9836 --- /dev/null +++ b/src/api/utils/videoPreview/FrameIntervalPreview.js @@ -0,0 +1,73 @@ +/* eslint-disable no-bitwise */ +const ffmpeg = require('fluent-ffmpeg'); +const probe = require('ffmpeg-probe'); + +const noop = () => {}; + +module.exports = async (opts) => { + const { + log = noop, + + // general output options + quality = 2, + width, + height, + input, + output, + + numFrames, + numFramesPercent = 0.05 + } = opts; + + const info = await probe(input); + // const numFramesTotal = parseInt(info.streams[0].nb_frames, 10); + const { avg_frame_rate: avgFrameRate, duration } = info.streams[0]; + const [frames, time] = avgFrameRate.split('/').map((e) => parseInt(e, 10)); + + const numFramesTotal = (frames / time) * duration; + + let numFramesToCapture = numFrames || numFramesPercent * numFramesTotal; + numFramesToCapture = Math.max(1, Math.min(numFramesTotal, numFramesToCapture)) | 0; + const nthFrame = (numFramesTotal / numFramesToCapture) | 0; + + const result = { + output, + numFrames: numFramesToCapture + }; + + await new Promise((resolve, reject) => { + let scale = null; + + if (width && height) { + result.width = width | 0; + result.height = height | 0; + scale = `scale=${width}:${height}`; + } else if (width) { + result.width = width | 0; + result.height = ((info.height * width) / info.width) | 0; + scale = `scale=${width}:-1`; + } else if (height) { + result.height = height | 0; + result.width = ((info.width * height) / info.height) | 0; + scale = `scale=-1:${height}`; + } else { + result.width = info.width; + result.height = info.height; + } + + const filter = [`select=not(mod(n\\,${nthFrame}))`, scale].filter(Boolean).join(','); + + ffmpeg(input) + .outputOptions(['-vsync', 'vfr']) + .outputOptions(['-q:v', quality, '-vf', filter]) + .noAudio() + .outputFormat('webm') + .output(output) + .on('start', (cmd) => log && log({ cmd })) + .on('end', () => resolve()) + .on('error', (err) => reject(err)) + .run(); + }); + + return result; +}; |