From 8774e6be718147759cf33412c896568f4eb892c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 22 Aug 2024 17:39:28 -0500 Subject: [PATCH] Update:Create library endpoint to create using new model, adding additional validation --- server/Database.js | 5 - server/controllers/LibraryController.js | 116 +++++++++++++++++++----- server/models/Library.js | 51 ++++++----- server/objects/Library.js | 51 +++-------- 4 files changed, 134 insertions(+), 89 deletions(-) diff --git a/server/Database.js b/server/Database.js index 25056fdd..d3966e92 100644 --- a/server/Database.js +++ b/server/Database.js @@ -384,11 +384,6 @@ class Database { return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) } - createLibrary(oldLibrary) { - if (!this.sequelize) return false - return this.models.library.createFromOld(oldLibrary) - } - updateLibrary(oldLibrary) { if (!this.sequelize) return false return this.models.library.updateFromOld(oldLibrary) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 48021a30..31e6e2da 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -41,58 +41,126 @@ class LibraryController { * @param {Response} res */ async create(req, res) { - const newLibraryPayload = { - ...req.body + if (!req.user.isAdminOrUp) { + Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to create library`) + return res.sendStatus(403) } - if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) { - return res.status(500).send('Invalid request') + + // Validation + if (!req.body.name || typeof req.body.name !== 'string') { + return res.status(400).send('Invalid request. Name must be a string') + } + if ( + !Array.isArray(req.body.folders) || + req.body.folders.some((f) => { + // Old model uses fullPath and new model will use path. Support both for now + const path = f?.fullPath || f?.path + return !path || typeof path !== 'string' + }) + ) { + return res.status(400).send('Invalid request. Folders must be a non-empty array of objects with path string') + } + const optionalStringFields = ['mediaType', 'icon', 'provider'] + for (const field of optionalStringFields) { + if (req.body[field] && typeof req.body[field] !== 'string') { + return res.status(400).send(`Invalid request. ${field} must be a string`) + } + } + if (req.body.settings && (typeof req.body.settings !== 'object' || Array.isArray(req.body.settings))) { + return res.status(400).send('Invalid request. Settings must be an object') + } + + const mediaType = req.body.mediaType || 'book' + const newLibraryPayload = { + name: req.body.name, + provider: req.body.provider || 'google', + mediaType, + icon: req.body.icon || 'database', + settings: Database.libraryModel.getDefaultLibrarySettingsForMediaType(mediaType) + } + + // Validate settings + if (req.body.settings) { + for (const key in req.body.settings) { + if (newLibraryPayload.settings[key] !== undefined) { + if (key === 'metadataPrecedence') { + if (!Array.isArray(req.body.settings[key])) { + return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array') + } + newLibraryPayload.settings[key] = [...req.body.settings[key]] + } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') { + if (!req.body.settings[key]) continue + if (typeof req.body.settings[key] !== 'string') { + return res.status(400).send(`Invalid request. Settings "${key}" must be a string`) + } + newLibraryPayload.settings[key] = req.body.settings[key] + } else { + if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) { + return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`) + } + newLibraryPayload.settings[key] = req.body.settings[key] + } + } + } } // Validate that the custom provider exists if given any - if (newLibraryPayload.provider?.startsWith('custom-')) { + if (newLibraryPayload.provider.startsWith('custom-')) { if (!(await Database.customMetadataProviderModel.checkExistsBySlug(newLibraryPayload.provider))) { Logger.error(`[LibraryController] Custom metadata provider "${newLibraryPayload.provider}" does not exist`) - return res.status(400).send('Custom metadata provider does not exist') + return res.status(400).send('Invalid request. Custom metadata provider does not exist') } } // Validate folder paths exist or can be created & resolve rel paths // returns 400 if a folder fails to access - newLibraryPayload.folders = newLibraryPayload.folders.map((f) => { - f.fullPath = fileUtils.filePathToPOSIX(Path.resolve(f.fullPath)) + newLibraryPayload.libraryFolders = req.body.folders.map((f) => { + const fpath = f.fullPath || f.path + f.path = fileUtils.filePathToPOSIX(Path.resolve(fpath)) return f }) - for (const folder of newLibraryPayload.folders) { + for (const folder of newLibraryPayload.libraryFolders) { try { - const direxists = await fs.pathExists(folder.fullPath) - if (!direxists) { - // If folder does not exist try to make it and set file permissions/owner - await fs.mkdir(folder.fullPath) - } + // Create folder if it doesn't exist + await fs.ensureDir(folder.path) } catch (error) { - Logger.error(`[LibraryController] Failed to ensure folder dir "${folder.fullPath}"`, error) - return res.status(400).send(`Invalid folder directory "${folder.fullPath}"`) + Logger.error(`[LibraryController] Failed to ensure folder dir "${folder.path}"`, error) + return res.status(400).send(`Invalid request. Invalid folder directory "${folder.path}"`) } } - const library = new Library() - + // Set display order let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder() if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0 newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1 - library.setData(newLibraryPayload) - await Database.createLibrary(library) + + // Create library with libraryFolders + const library = await Database.libraryModel + .create(newLibraryPayload, { + include: Database.libraryFolderModel + }) + .catch((error) => { + Logger.error(`[LibraryController] Failed to create library "${newLibraryPayload.name}"`, error) + }) + if (!library) { + return res.status(500).send('Failed to create library') + } + + library.libraryFolders = await library.getLibraryFolders() + + // TODO: Migrate to new library model + const oldLibrary = Database.libraryModel.getOldLibrary(library) // Only emit to users with access to library const userFilter = (user) => { - return user.checkCanAccessLibrary?.(library.id) + return user.checkCanAccessLibrary?.(oldLibrary.id) } - SocketAuthority.emitter('library_added', library.toJSON(), userFilter) + SocketAuthority.emitter('library_added', oldLibrary.toJSON(), userFilter) // Add library watcher - this.watcher.addLibrary(library) + this.watcher.addLibrary(oldLibrary) - res.json(library) + res.json(oldLibrary) } async findAll(req, res) { diff --git a/server/models/Library.js b/server/models/Library.js index 61706350..9b42adea 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -45,6 +45,35 @@ class Library extends Model { this.updatedAt } + /** + * + * @param {string} mediaType + * @returns + */ + static getDefaultLibrarySettingsForMediaType(mediaType) { + if (mediaType === 'podcast') { + return { + coverAspectRatio: 1, // Square + disableWatcher: false, + autoScanCronExpression: null, + podcastSearchRegion: 'us' + } + } else { + return { + coverAspectRatio: 1, // Square + disableWatcher: false, + autoScanCronExpression: null, + skipMatchingMediaWithAsin: false, + skipMatchingMediaWithIsbn: false, + audiobooksOnly: false, + epubsAllowScriptedContent: false, + hideSingleBookSeries: false, + onlyShowLaterBooksInContinueSeries: false, + metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] + } + } + } + /** * Get all old libraries * @returns {Promise} @@ -89,28 +118,6 @@ class Library extends Model { }) } - /** - * @param {object} oldLibrary - * @returns {Library|null} - */ - static async createFromOld(oldLibrary) { - const library = this.getFromOld(oldLibrary) - - library.libraryFolders = oldLibrary.folders.map((folder) => { - return { - id: folder.id, - path: folder.fullPath - } - }) - - return this.create(library, { - include: this.sequelize.models.libraryFolder - }).catch((error) => { - Logger.error(`[Library] Failed to create library ${library.id}`, error) - return null - }) - } - /** * Update library and library folders * @param {object} oldLibrary diff --git a/server/objects/Library.js b/server/objects/Library.js index 6c1952dc..98b6ec39 100644 --- a/server/objects/Library.js +++ b/server/objects/Library.js @@ -1,4 +1,4 @@ -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const Folder = require('./Folder') const LibrarySettings = require('./settings/LibrarySettings') const { filePathToPOSIX } = require('../utils/fileUtils') @@ -29,7 +29,7 @@ class Library { } get folderPaths() { - return this.folders.map(f => f.fullPath) + return this.folders.map((f) => f.fullPath) } get isPodcast() { return this.mediaType === 'podcast' @@ -45,14 +45,15 @@ class Library { this.id = library.id this.oldLibraryId = library.oldLibraryId this.name = library.name - this.folders = (library.folders || []).map(f => new Folder(f)) + this.folders = (library.folders || []).map((f) => new Folder(f)) this.displayOrder = library.displayOrder || 1 this.icon = library.icon || 'database' this.mediaType = library.mediaType this.provider = library.provider || 'google' this.settings = new LibrarySettings(library.settings) - if (library.settings === undefined) { // LibrarySettings added in v2, migrate settings + if (library.settings === undefined) { + // LibrarySettings added in v2, migrate settings this.settings.disableWatcher = !!library.disableWatcher } @@ -85,7 +86,7 @@ class Library { id: this.id, oldLibraryId: this.oldLibraryId, name: this.name, - folders: (this.folders || []).map(f => f.toJSON()), + folders: (this.folders || []).map((f) => f.toJSON()), displayOrder: this.displayOrder, icon: this.icon, mediaType: this.mediaType, @@ -98,32 +99,6 @@ class Library { } } - setData(data) { - this.id = data.id || uuidv4() - this.name = data.name - if (data.folder) { - this.folders = [ - new Folder(data.folder) - ] - } else if (data.folders) { - this.folders = data.folders.map(folder => { - var newFolder = new Folder() - newFolder.setData({ - fullPath: folder.fullPath, - libraryId: this.id - }) - return newFolder - }) - } - this.displayOrder = data.displayOrder || 1 - this.icon = data.icon || 'database' - this.mediaType = data.mediaType || 'book' - this.provider = data.provider || 'google' - this.settings = new LibrarySettings(data.settings) - this.createdAt = Date.now() - this.lastUpdate = Date.now() - } - update(payload) { let hasUpdates = false @@ -144,12 +119,12 @@ class Library { hasUpdates = true } if (payload.folders) { - const newFolders = payload.folders.filter(f => !f.id) - const removedFolders = this.folders.filter(f => !payload.folders.some(_f => _f.id === f.id)) + const newFolders = payload.folders.filter((f) => !f.id) + const removedFolders = this.folders.filter((f) => !payload.folders.some((_f) => _f.id === f.id)) if (removedFolders.length) { - const removedFolderIds = removedFolders.map(f => f.id) - this.folders = this.folders.filter(f => !removedFolderIds.includes(f.id)) + const removedFolderIds = removedFolders.map((f) => f.id) + this.folders = this.folders.filter((f) => !removedFolderIds.includes(f.id)) } if (newFolders.length) { @@ -173,11 +148,11 @@ class Library { checkFullPathInLibrary(fullPath) { fullPath = filePathToPOSIX(fullPath) - return this.folders.find(folder => fullPath.startsWith(filePathToPOSIX(folder.fullPath))) + return this.folders.find((folder) => fullPath.startsWith(filePathToPOSIX(folder.fullPath))) } getFolderById(id) { - return this.folders.find(folder => folder.id === id) + return this.folders.find((folder) => folder.id === id) } } -module.exports = Library \ No newline at end of file +module.exports = Library