From 4f30cbf2f652362f2fcb4ac1baa1af3104ad9fb5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 14 Oct 2025 18:09:32 +0300 Subject: [PATCH 01/16] SearchController: New providers API, query param validation --- server/controllers/SearchController.js | 298 ++++++++++++++++++++++--- 1 file changed, 267 insertions(+), 31 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index bb3382f71..72f602d2a 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -6,6 +6,28 @@ const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') const { isValidASIN } = require('../utils') +// Provider name mappings for display purposes +const providerMap = { + all: 'All', + best: 'Best', + google: 'Google Books', + itunes: 'iTunes', + openlibrary: 'Open Library', + fantlab: 'FantLab.ru', + audiobookcovers: 'AudiobookCovers.com', + audible: 'Audible.com', + 'audible.ca': 'Audible.ca', + 'audible.uk': 'Audible.co.uk', + 'audible.au': 'Audible.com.au', + 'audible.fr': 'Audible.fr', + 'audible.de': 'Audible.de', + 'audible.jp': 'Audible.co.jp', + 'audible.it': 'Audible.it', + 'audible.in': 'Audible.in', + 'audible.es': 'Audible.es', + audnexus: 'Audnexus' +} + /** * @typedef RequestUserObject * @property {import('../models/User')} user @@ -16,6 +38,100 @@ const { isValidASIN } = require('../utils') class SearchController { constructor() {} + /** + * Validates that multiple parameters are strings + * @param {Object} params - Object with param names as keys and values to validate + * @param {string} methodName - Name of the calling method for logging + * @returns {{valid: boolean, error?: {status: number, message: string}}} + */ + static validateStringParams(params, methodName) { + for (const [key, value] of Object.entries(params)) { + if (typeof value !== 'string') { + Logger.error(`[SearchController] ${methodName}: Invalid ${key} parameter`) + return { + valid: false, + error: { + status: 400, + message: 'Invalid request query params' + } + } + } + } + return { valid: true } + } + + /** + * Validates that a required string parameter exists and is a string + * @param {any} value - Value to validate + * @param {string} paramName - Parameter name for logging + * @param {string} methodName - Name of the calling method for logging + * @returns {{valid: boolean, error?: {status: number, message: string}}} + */ + static validateRequiredString(value, paramName, methodName) { + if (!value || typeof value !== 'string') { + Logger.error(`[SearchController] ${methodName}: Invalid or missing ${paramName}`) + return { + valid: false, + error: { + status: 400, + message: `Invalid or missing ${paramName}` + } + } + } + return { valid: true } + } + + /** + * Validates and fetches a library item by ID + * @param {string} id - Library item ID + * @param {string} methodName - Name of the calling method for logging + * @returns {Promise<{valid: boolean, libraryItem?: any, error?: {status: number, message: string}}>} + */ + static async fetchAndValidateLibraryItem(id, methodName) { + const validation = SearchController.validateRequiredString(id, 'library item id', methodName) + if (!validation.valid) { + return validation + } + + const libraryItem = await Database.libraryItemModel.getExpandedById(id) + if (!libraryItem) { + Logger.error(`[SearchController] ${methodName}: Library item not found with id "${id}"`) + return { + valid: false, + error: { + status: 404, + message: 'Library item not found' + } + } + } + + return { valid: true, libraryItem } + } + + /** + * Maps custom metadata providers to standardized format + * @param {Array} providers - Array of custom provider objects + * @returns {Array<{value: string, text: string}>} + */ + static mapCustomProviders(providers) { + return providers.map((provider) => ({ + value: provider.getSlug(), + text: provider.name + })) + } + + /** + * Static helper method to format provider for client (for use in array methods) + * @param {string} providerValue - Provider identifier + * @returns {{value: string, text: string}} + */ + static formatProvider(providerValue) { + return { + value: providerValue, + text: providerMap[providerValue] || providerValue + } + } + /** * GET: /api/search/books * @@ -23,18 +139,19 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - const id = req.query.id - const libraryItem = await Database.libraryItemModel.getExpandedById(id) const provider = req.query.provider || 'google' const title = req.query.title || '' const author = req.query.author || '' - if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') { - Logger.error(`[SearchController] findBooks: Invalid request query params`) - return res.status(400).send('Invalid request query params') - } + // Validate string parameters + const validation = SearchController.validateStringParams({ provider, title, author }, 'findBooks') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - const results = await BookFinder.search(libraryItem, provider, title, author) + // Fetch and validate library item + const itemValidation = await SearchController.fetchAndValidateLibraryItem(req.query.id, 'findBooks') + if (!itemValidation.valid) return res.status(itemValidation.error.status).send(itemValidation.error.message) + + const results = await BookFinder.search(itemValidation.libraryItem, provider, title, author) res.json(results) } @@ -46,19 +163,23 @@ class SearchController { */ async findCovers(req, res) { const query = req.query - const podcast = query.podcast == 1 + const podcast = query.podcast === '1' || query.podcast === 1 + const title = query.title || '' + const author = query.author || '' + const provider = query.provider || 'google' - if (!query.title || typeof query.title !== 'string') { - Logger.error(`[SearchController] findCovers: Invalid title sent in query`) - return res.sendStatus(400) - } + // Validate required title + const titleValidation = SearchController.validateRequiredString(title, 'title', 'findCovers') + if (!titleValidation.valid) return res.status(titleValidation.error.status).send(titleValidation.error.message) + + // Validate other string parameters + const validation = SearchController.validateStringParams({ author, provider }, 'findCovers') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) let results = null - if (podcast) results = await PodcastFinder.findCovers(query.title) - else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '') - res.json({ - results - }) + if (podcast) results = await PodcastFinder.findCovers(title) + else results = await BookFinder.findCovers(provider, title, author) + res.json({ results }) } /** @@ -71,14 +192,16 @@ class SearchController { async findPodcasts(req, res) { const term = req.query.term const country = req.query.country || 'us' - if (!term) { - Logger.error('[SearchController] Invalid request query param "term" is required') - return res.status(400).send('Invalid request query param "term" is required') - } - const results = await PodcastFinder.search(term, { - country - }) + // Validate required term + const termValidation = SearchController.validateRequiredString(term, 'term', 'findPodcasts') + if (!termValidation.valid) return res.status(termValidation.error.status).send(termValidation.error.message) + + // Validate country parameter + const validation = SearchController.validateStringParams({ country }, 'findPodcasts') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) + + const results = await PodcastFinder.search(term, { country }) res.json(results) } @@ -90,10 +213,10 @@ class SearchController { */ async findAuthor(req, res) { const query = req.query.q - if (!query || typeof query !== 'string') { - Logger.error(`[SearchController] findAuthor: Invalid query param`) - return res.status(400).send('Invalid query param') - } + + // Validate query parameter + const validation = SearchController.validateRequiredString(query, 'query', 'findAuthor') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) const author = await AuthorFinder.findAuthorByName(query) res.json(author) @@ -107,15 +230,128 @@ class SearchController { */ async findChapters(req, res) { const asin = req.query.asin - if (!isValidASIN(asin.toUpperCase())) { - return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) - } const region = (req.query.region || 'us').toLowerCase() + + // Validate ASIN parameter + const asinValidation = SearchController.validateRequiredString(asin, 'asin', 'findChapters') + if (!asinValidation.valid) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + + if (!isValidASIN(asin.toUpperCase())) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + + // Validate region parameter + const validation = SearchController.validateStringParams({ region }, 'findChapters') + if (!validation.valid) res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) + const chapterData = await BookFinder.findChapters(asin, region) if (!chapterData) { return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) } res.json(chapterData) } + + /** + * GET: /api/search/providers/podcasts/covers + * Get available podcast cover metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getPodcastCoverProviders(req, res) { + // Podcast covers only use iTunes + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'podcast' + } + }) + + const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/books/covers + * Get available book cover metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getBookCoverProviders(req, res) { + // Book covers use all book providers + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'book' + } + }) + + const providers = [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders), SearchController.formatProvider('all')] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/books + * Get available book metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getBookProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'book' + } + }) + + // Filter out cover-only providers + const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers') + + const providers = [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders)] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/podcasts + * Get available podcast metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getPodcastProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'podcast' + } + }) + + const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/authors + * Get available author metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getAuthorProviders(req, res) { + const providers = [SearchController.formatProvider('audnexus')] + res.json({ providers }) + } + + /** + * GET: /api/search/providers/chapters + * Get available chapter metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getChapterProviders(req, res) { + const providers = [SearchController.formatProvider('audnexus')] + res.json({ providers }) + } } module.exports = new SearchController() From 1da3ab7fdc819ad71271098bba1f186de058fc7a Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 14 Oct 2025 18:10:12 +0300 Subject: [PATCH 02/16] ApiRouter: New provider API routes --- server/routers/ApiRouter.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..c72aa143f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -283,6 +283,12 @@ class ApiRouter { this.router.get('/search/podcast', SearchController.findPodcasts.bind(this)) this.router.get('/search/authors', SearchController.findAuthor.bind(this)) this.router.get('/search/chapters', SearchController.findChapters.bind(this)) + this.router.get('/search/providers/books', SearchController.getBookProviders.bind(this)) + this.router.get('/search/providers/books/covers', SearchController.getBookCoverProviders.bind(this)) + this.router.get('/search/providers/podcasts', SearchController.getPodcastProviders.bind(this)) + this.router.get('/search/providers/podcasts/covers', SearchController.getPodcastCoverProviders.bind(this)) + this.router.get('/search/providers/authors', SearchController.getAuthorProviders.bind(this)) + this.router.get('/search/providers/chapters', SearchController.getChapterProviders.bind(this)) // // Cache Routes (Admin and up) From ce4ff4f894ea5e924ac64429c3fa8fa772f210d0 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Oct 2025 09:52:15 +0300 Subject: [PATCH 03/16] Client: Use new server providers API --- .../modals/BatchQuickMatchModel.vue | 2 +- client/components/modals/item/tabs/Cover.vue | 4 +- client/components/modals/item/tabs/Match.vue | 2 +- .../modals/libraries/EditLibrary.vue | 2 +- .../modals/libraries/LibrarySettings.vue | 2 +- client/layouts/default.vue | 24 ++- client/pages/config/index.vue | 3 +- client/pages/upload/index.vue | 2 +- client/store/libraries.js | 2 - client/store/scanners.js | 171 ++++++------------ 10 files changed, 90 insertions(+), 124 deletions(-) diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index 8bea68faf..138684b20 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -88,7 +88,7 @@ export default { }, providers() { if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index b404a9abb..4ed734b1b 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -133,8 +133,8 @@ export default { } }, providers() { - if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return [{ text: 'Best', value: 'best' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders, { text: 'All', value: 'all' }] + if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders + return this.$store.state.scanners.bookCoverProviders }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 3faa26b27..36cdd7a8e 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -319,7 +319,7 @@ export default { }, providers() { if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 083fc5766..1e4d3990a 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -74,7 +74,7 @@ export default { }, providers() { if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders } }, methods: { diff --git a/client/components/modals/libraries/LibrarySettings.vue b/client/components/modals/libraries/LibrarySettings.vue index d3b40de95..231cb5ec4 100644 --- a/client/components/modals/libraries/LibrarySettings.vue +++ b/client/components/modals/libraries/LibrarySettings.vue @@ -136,7 +136,7 @@ export default { }, providers() { if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, maskAsFinishedWhenItems() { return [ diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 4b9729248..21c256bd3 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -371,11 +371,25 @@ export default { }, customMetadataProviderAdded(provider) { if (!provider?.id) return - this.$store.commit('scanners/addCustomMetadataProvider', provider) + // Refetch the appropriate provider types + if (provider.mediaType === 'book') { + this.$store.dispatch('scanners/fetchBookProviders') + this.$store.dispatch('scanners/fetchBookCoverProviders') + } else if (provider.mediaType === 'podcast') { + this.$store.dispatch('scanners/fetchPodcastProviders') + this.$store.dispatch('scanners/fetchPodcastCoverProviders') + } }, customMetadataProviderRemoved(provider) { if (!provider?.id) return - this.$store.commit('scanners/removeCustomMetadataProvider', provider) + // Refetch the appropriate provider types + if (provider.mediaType === 'book') { + this.$store.dispatch('scanners/fetchBookProviders') + this.$store.dispatch('scanners/fetchBookCoverProviders') + } else if (provider.mediaType === 'podcast') { + this.$store.dispatch('scanners/fetchPodcastProviders') + this.$store.dispatch('scanners/fetchPodcastCoverProviders') + } }, initializeSocket() { if (this.$root.socket) { @@ -612,6 +626,12 @@ export default { this.$store.dispatch('libraries/load') + // Fetch metadata providers + this.$store.dispatch('scanners/fetchBookProviders') + this.$store.dispatch('scanners/fetchBookCoverProviders') + this.$store.dispatch('scanners/fetchPodcastProviders') + this.$store.dispatch('scanners/fetchPodcastCoverProviders') + this.initLocalStorage() this.checkVersionUpdate() diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 3d030bb32..099ae9c4e 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -247,7 +247,8 @@ export default { return this.$store.state.serverSettings }, providers() { - return this.$store.state.scanners.providers + // Use book cover providers for the cover provider dropdown + return this.$store.state.scanners.bookCoverProviders || [] }, dateFormats() { return this.$store.state.globals.dateFormats diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index eef05b608..84d4d1478 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -155,7 +155,7 @@ export default { }, providers() { if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, canFetchMetadata() { return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled diff --git a/client/store/libraries.js b/client/store/libraries.js index 115fb53bf..a824b6474 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -131,8 +131,6 @@ export const actions = { commit('setLibraryIssues', issues) commit('setLibraryFilterData', filterData) commit('setNumUserPlaylists', numUserPlaylists) - commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true }) - commit('setCurrentLibrary', { id: libraryId }) return data }) diff --git a/client/store/scanners.js b/client/store/scanners.js index 2d3d465ca..40a8d864b 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -1,126 +1,73 @@ export const state = () => ({ - providers: [ - { - text: 'Google Books', - value: 'google' - }, - { - text: 'Open Library', - value: 'openlibrary' - }, - { - text: 'iTunes', - value: 'itunes' - }, - { - text: 'Audible.com', - value: 'audible' - }, - { - text: 'Audible.ca', - value: 'audible.ca' - }, - { - text: 'Audible.co.uk', - value: 'audible.uk' - }, - { - text: 'Audible.com.au', - value: 'audible.au' - }, - { - text: 'Audible.fr', - value: 'audible.fr' - }, - { - text: 'Audible.de', - value: 'audible.de' - }, - { - text: 'Audible.co.jp', - value: 'audible.jp' - }, - { - text: 'Audible.it', - value: 'audible.it' - }, - { - text: 'Audible.co.in', - value: 'audible.in' - }, - { - text: 'Audible.es', - value: 'audible.es' - }, - { - text: 'FantLab.ru', - value: 'fantlab' - } - ], - podcastProviders: [ - { - text: 'iTunes', - value: 'itunes' - } - ], - coverOnlyProviders: [ - { - text: 'AudiobookCovers.com', - value: 'audiobookcovers' - } - ] + bookProviders: [], + podcastProviders: [], + bookCoverProviders: [], + podcastCoverProviders: [] }) export const getters = { - checkBookProviderExists: state => (providerValue) => { - return state.providers.some(p => p.value === providerValue) + checkBookProviderExists: (state) => (providerValue) => { + return state.bookProviders.some((p) => p.value === providerValue) }, - checkPodcastProviderExists: state => (providerValue) => { - return state.podcastProviders.some(p => p.value === providerValue) + checkPodcastProviderExists: (state) => (providerValue) => { + return state.podcastProviders.some((p) => p.value === providerValue) } } -export const actions = {} - -export const mutations = { - addCustomMetadataProvider(state, provider) { - if (provider.mediaType === 'book') { - if (state.providers.some(p => p.value === provider.slug)) return - state.providers.push({ - text: provider.name, - value: provider.slug - }) - } else { - if (state.podcastProviders.some(p => p.value === provider.slug)) return - state.podcastProviders.push({ - text: provider.name, - value: provider.slug - }) +export const actions = { + async fetchBookProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/books') + if (response?.providers) { + commit('setBookProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch book providers', error) } }, - removeCustomMetadataProvider(state, provider) { - if (provider.mediaType === 'book') { - state.providers = state.providers.filter(p => p.value !== provider.slug) - } else { - state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug) + async fetchPodcastProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/podcasts') + if (response?.providers) { + commit('setPodcastProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch podcast providers', error) } }, - setCustomMetadataProviders(state, providers) { - if (!providers?.length) return - - const mediaType = providers[0].mediaType - if (mediaType === 'book') { - // clear previous values, and add new values to the end - state.providers = state.providers.filter((p) => !p.value.startsWith('custom-')) - state.providers = [ - ...state.providers, - ...providers.map((p) => ({ - text: p.name, - value: p.slug - })) - ] - } else { - // Podcast providers not supported yet + async fetchBookCoverProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/books/covers') + if (response?.providers) { + commit('setBookCoverProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch book cover providers', error) + } + }, + async fetchPodcastCoverProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/podcasts/covers') + if (response?.providers) { + commit('setPodcastCoverProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch podcast cover providers', error) } } -} \ No newline at end of file +} + +export const mutations = { + setBookProviders(state, providers) { + state.bookProviders = providers + }, + setPodcastProviders(state, providers) { + state.podcastProviders = providers + }, + setBookCoverProviders(state, providers) { + state.bookCoverProviders = providers + }, + setPodcastCoverProviders(state, providers) { + state.podcastCoverProviders = providers + } +} From 888190a6be4c103b6eec3e5c0f97f678eda87900 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Oct 2025 18:28:15 +0300 Subject: [PATCH 04/16] Fix codeQL failures --- server/controllers/SearchController.js | 25 +++++++++++++------------ server/finders/BookFinder.js | 7 +++++-- server/utils/index.js | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 72f602d2a..57538d2c0 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -4,7 +4,7 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') -const { isValidASIN } = require('../utils') +const { isValidASIN, getQueryParamAsString } = require('../utils') // Provider name mappings for display purposes const providerMap = { @@ -139,9 +139,10 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - const provider = req.query.provider || 'google' - const title = req.query.title || '' - const author = req.query.author || '' + // Safely extract query parameters, rejecting arrays to prevent type confusion + const provider = getQueryParamAsString(req.query.provider, 'google') + const title = getQueryParamAsString(req.query.title, '') + const author = getQueryParamAsString(req.query.author, '') // Validate string parameters const validation = SearchController.validateStringParams({ provider, title, author }, 'findBooks') @@ -164,9 +165,9 @@ class SearchController { async findCovers(req, res) { const query = req.query const podcast = query.podcast === '1' || query.podcast === 1 - const title = query.title || '' - const author = query.author || '' - const provider = query.provider || 'google' + const title = getQueryParamAsString(query.title, '') + const author = getQueryParamAsString(query.author, '') + const provider = getQueryParamAsString(query.provider, 'google') // Validate required title const titleValidation = SearchController.validateRequiredString(title, 'title', 'findCovers') @@ -190,8 +191,8 @@ class SearchController { * @param {Response} res */ async findPodcasts(req, res) { - const term = req.query.term - const country = req.query.country || 'us' + const term = getQueryParamAsString(req.query.term) + const country = getQueryParamAsString(req.query.country, 'us') // Validate required term const termValidation = SearchController.validateRequiredString(term, 'term', 'findPodcasts') @@ -212,7 +213,7 @@ class SearchController { * @param {Response} res */ async findAuthor(req, res) { - const query = req.query.q + const query = getQueryParamAsString(req.query.q) // Validate query parameter const validation = SearchController.validateRequiredString(query, 'query', 'findAuthor') @@ -229,8 +230,8 @@ class SearchController { * @param {Response} res */ async findChapters(req, res) { - const asin = req.query.asin - const region = (req.query.region || 'us').toLowerCase() + const asin = getQueryParamAsString(req.query.asin) + const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() // Validate ASIN parameter const asinValidation = SearchController.validateRequiredString(asin, 'asin', 'findChapters') diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index a6a6b07e6..6dc90c44f 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -402,7 +402,8 @@ class BookFinder { let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator - const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, ' - ') + // Use negated character classes to prevent ReDoS vulnerability + const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ') // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) authorCandidates.add(titlePart) @@ -668,7 +669,9 @@ function cleanTitleForCompares(title, keepSubtitle = false) { let stripped = keepSubtitle ? title : stripSubtitle(title) // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - let cleaned = stripped.replace(/ *\([^)]*\) */g, '') + // Use a safe two-pass approach to prevent ReDoS vulnerability + let cleaned = stripped.replace(/\([^)]*\)/g, '') // Remove parenthetical content + cleaned = cleaned.replace(/\s+/g, ' ').trim() // Clean up any resulting multiple spaces // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") cleaned = cleaned.replace(/'/g, '') diff --git a/server/utils/index.js b/server/utils/index.js index 369620276..4421fbade 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -277,3 +277,22 @@ module.exports.timestampToSeconds = (timestamp) => { } return null } + +/** + * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion + * Express query parameters can be arrays if the same parameter appears multiple times + * @example ?author=Smith => "Smith" + * @example ?author=Smith&author=Jones => null (array detected) + * + * @param {any} value - Query parameter value + * @param {string} defaultValue - Default value if undefined/null + * @returns {string|null} String value or null if invalid (array) + */ +module.exports.getQueryParamAsString = (value, defaultValue = '') => { + // Explicitly reject arrays to prevent type confusion + if (Array.isArray(value)) { + return null + } + // Return default for undefined/null, otherwise return the value + return value == null ? defaultValue : value +} From 3f6162f53c3246963cbbaaa1697043a3119cfd89 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Oct 2025 18:54:29 +0300 Subject: [PATCH 05/16] CodeQL fix: limit parameter sizes --- server/finders/BookFinder.js | 9 +++++++-- server/utils/index.js | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 6dc90c44f..fe1a61027 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -385,6 +385,11 @@ class BookFinder { if (!title) return books + // Truncate excessively long inputs to prevent ReDoS attacks + const MAX_INPUT_LENGTH = 500 + title = title.substring(0, MAX_INPUT_LENGTH) + author = author?.substring(0, MAX_INPUT_LENGTH) || author + const isTitleAsin = isValidASIN(title.toUpperCase()) let actualTitleQuery = title @@ -402,7 +407,7 @@ class BookFinder { let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator - // Use negated character classes to prevent ReDoS vulnerability + // Use negated character classes to prevent ReDoS vulnerability (input length validated at entry point) const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ') // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) @@ -669,7 +674,7 @@ function cleanTitleForCompares(title, keepSubtitle = false) { let stripped = keepSubtitle ? title : stripSubtitle(title) // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - // Use a safe two-pass approach to prevent ReDoS vulnerability + // Use negated character class to prevent ReDoS vulnerability (input length validated at entry point) let cleaned = stripped.replace(/\([^)]*\)/g, '') // Remove parenthetical content cleaned = cleaned.replace(/\s+/g, ' ').trim() // Clean up any resulting multiple spaces diff --git a/server/utils/index.js b/server/utils/index.js index 4421fbade..0661d14f8 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -286,13 +286,21 @@ module.exports.timestampToSeconds = (timestamp) => { * * @param {any} value - Query parameter value * @param {string} defaultValue - Default value if undefined/null - * @returns {string|null} String value or null if invalid (array) + * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks) + * @returns {string|null} String value or null if invalid (array or too long) */ -module.exports.getQueryParamAsString = (value, defaultValue = '') => { +module.exports.getQueryParamAsString = (value, defaultValue = '', maxLength = 1000) => { // Explicitly reject arrays to prevent type confusion if (Array.isArray(value)) { return null } - // Return default for undefined/null, otherwise return the value - return value == null ? defaultValue : value + // Return default for undefined/null + if (value == null) { + return defaultValue + } + // Reject excessively long strings to prevent ReDoS attacks + if (typeof value === 'string' && value.length > maxLength) { + return null + } + return value } From 0a82d6a41b5662ee681bdecf1e922824f427fda5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 17 Oct 2025 08:11:03 +0300 Subject: [PATCH 06/16] CoverSearchManager: Fix broken podcast cover search --- server/managers/CoverSearchManager.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/managers/CoverSearchManager.js b/server/managers/CoverSearchManager.js index ddcaa23db..193176766 100644 --- a/server/managers/CoverSearchManager.js +++ b/server/managers/CoverSearchManager.js @@ -224,6 +224,9 @@ class CoverSearchManager { if (!Array.isArray(results)) return covers results.forEach((result) => { + if (typeof result === 'string') { + covers.push(result) + } if (result.covers && Array.isArray(result.covers)) { covers.push(...result.covers) } From 0a8662d1983736ea36fb76a3bfd028f6e2586106 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 10:53:27 +0300 Subject: [PATCH 07/16] Merge providers API into a single endpoint --- server/controllers/SearchController.js | 104 +++---------------------- server/routers/ApiRouter.js | 7 +- 2 files changed, 13 insertions(+), 98 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 57538d2c0..72d215f34 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -251,108 +251,28 @@ class SearchController { } /** - * GET: /api/search/providers/podcasts/covers - * Get available podcast cover metadata providers + * GET: /api/search/providers + * Get all available metadata providers * * @param {RequestWithUser} req * @param {Response} res */ - async getPodcastCoverProviders(req, res) { - // Podcast covers only use iTunes - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'podcast' - } - }) + async getAllProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll() - const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] + const customBookProviders = customProviders.filter((p) => p.mediaType === 'book') + const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast') - res.json({ providers }) - } - - /** - * GET: /api/search/providers/books/covers - * Get available book cover metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getBookCoverProviders(req, res) { - // Book covers use all book providers - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'book' - } - }) - - const providers = [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders), SearchController.formatProvider('all')] - - res.json({ providers }) - } - - /** - * GET: /api/search/providers/books - * Get available book metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getBookProviders(req, res) { - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'book' - } - }) - - // Filter out cover-only providers const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers') - const providers = [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders)] + // Build minimized payload with custom providers merged in + const providers = { + books: [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders)], + booksCovers: [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders), SearchController.formatProvider('all')], + podcasts: [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customPodcastProviders)] + } res.json({ providers }) } - - /** - * GET: /api/search/providers/podcasts - * Get available podcast metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getPodcastProviders(req, res) { - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'podcast' - } - }) - - const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] - - res.json({ providers }) - } - - /** - * GET: /api/search/providers/authors - * Get available author metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getAuthorProviders(req, res) { - const providers = [SearchController.formatProvider('audnexus')] - res.json({ providers }) - } - - /** - * GET: /api/search/providers/chapters - * Get available chapter metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getChapterProviders(req, res) { - const providers = [SearchController.formatProvider('audnexus')] - res.json({ providers }) - } } module.exports = new SearchController() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c72aa143f..db04bf5ec 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -283,12 +283,7 @@ class ApiRouter { this.router.get('/search/podcast', SearchController.findPodcasts.bind(this)) this.router.get('/search/authors', SearchController.findAuthor.bind(this)) this.router.get('/search/chapters', SearchController.findChapters.bind(this)) - this.router.get('/search/providers/books', SearchController.getBookProviders.bind(this)) - this.router.get('/search/providers/books/covers', SearchController.getBookCoverProviders.bind(this)) - this.router.get('/search/providers/podcasts', SearchController.getPodcastProviders.bind(this)) - this.router.get('/search/providers/podcasts/covers', SearchController.getPodcastCoverProviders.bind(this)) - this.router.get('/search/providers/authors', SearchController.getAuthorProviders.bind(this)) - this.router.get('/search/providers/chapters', SearchController.getChapterProviders.bind(this)) + this.router.get('/search/providers', SearchController.getAllProviders.bind(this)) // // Cache Routes (Admin and up) From b01e7570d398b20e300c87a80d61a82aa903041b Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 10:54:26 +0300 Subject: [PATCH 08/16] Remove custom providers from library filterdata request --- server/controllers/LibraryController.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index e63441f0b..55ef45690 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -221,13 +221,11 @@ class LibraryController { const includeArray = (req.query.include || '').split(',') if (includeArray.includes('filterdata')) { const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id) - const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType) return res.json({ filterdata, issues: filterdata.numIssues, numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id), - customMetadataProviders, library: req.library.toOldJSON() }) } From 141211590f15c2ac6485e6ed09e8fe0f57a048f8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 11:39:10 +0300 Subject: [PATCH 09/16] Merge provider actions and mutations, add loaded state --- client/store/scanners.js | 67 ++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/client/store/scanners.js b/client/store/scanners.js index 40a8d864b..ccf7d9249 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -2,7 +2,8 @@ export const state = () => ({ bookProviders: [], podcastProviders: [], bookCoverProviders: [], - podcastCoverProviders: [] + podcastCoverProviders: [], + providersLoaded: false }) export const getters = { @@ -11,63 +12,49 @@ export const getters = { }, checkPodcastProviderExists: (state) => (providerValue) => { return state.podcastProviders.some((p) => p.value === providerValue) - } + }, + areProvidersLoaded: (state) => state.providersLoaded } export const actions = { - async fetchBookProviders({ commit }) { + async fetchProviders({ commit, state }) { + // Only fetch if not already loaded + if (state.providersLoaded) { + return + } + try { - const response = await this.$axios.$get('/api/search/providers/books') + const response = await this.$axios.$get('/api/search/providers') if (response?.providers) { - commit('setBookProviders', response.providers) + commit('setAllProviders', response.providers) } } catch (error) { - console.error('Failed to fetch book providers', error) + console.error('Failed to fetch providers', error) } }, - async fetchPodcastProviders({ commit }) { - try { - const response = await this.$axios.$get('/api/search/providers/podcasts') - if (response?.providers) { - commit('setPodcastProviders', response.providers) - } - } catch (error) { - console.error('Failed to fetch podcast providers', error) + async refreshProviders({ commit, state }) { + // if providers are not loaded, do nothing - they will be fetched when required ( + if (!state.providersLoaded) { + return } - }, - async fetchBookCoverProviders({ commit }) { + try { - const response = await this.$axios.$get('/api/search/providers/books/covers') + const response = await this.$axios.$get('/api/search/providers') if (response?.providers) { - commit('setBookCoverProviders', response.providers) + commit('setAllProviders', response.providers) } } catch (error) { - console.error('Failed to fetch book cover providers', error) - } - }, - async fetchPodcastCoverProviders({ commit }) { - try { - const response = await this.$axios.$get('/api/search/providers/podcasts/covers') - if (response?.providers) { - commit('setPodcastCoverProviders', response.providers) - } - } catch (error) { - console.error('Failed to fetch podcast cover providers', error) + console.error('Failed to refresh providers', error) } } } export const mutations = { - setBookProviders(state, providers) { - state.bookProviders = providers - }, - setPodcastProviders(state, providers) { - state.podcastProviders = providers - }, - setBookCoverProviders(state, providers) { - state.bookCoverProviders = providers - }, - setPodcastCoverProviders(state, providers) { - state.podcastCoverProviders = providers + setAllProviders(state, providers) { + state.bookProviders = providers.books || [] + state.podcastProviders = providers.podcasts || [] + state.bookCoverProviders = providers.booksCovers || [] + state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only + state.providersLoaded = true } } From 816a47a4bac71ed4415e4923e99980a0150ce049 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 11:40:40 +0300 Subject: [PATCH 10/16] Remove custom providers from library fetch action --- client/store/libraries.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/store/libraries.js b/client/store/libraries.js index a824b6474..a6cf1dd39 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -117,7 +117,6 @@ export const actions = { const library = data.library const filterData = data.filterdata const issues = data.issues || 0 - const customMetadataProviders = data.customMetadataProviders || [] const numUserPlaylists = data.numUserPlaylists dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) From 166e0442a0e5763f62d5f82d76543fd13ec50510 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 11:47:17 +0300 Subject: [PATCH 11/16] Remove providers prefetch, refresh on custom provider add/remove --- client/layouts/default.vue | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 21c256bd3..75753b214 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -371,25 +371,13 @@ export default { }, customMetadataProviderAdded(provider) { if (!provider?.id) return - // Refetch the appropriate provider types - if (provider.mediaType === 'book') { - this.$store.dispatch('scanners/fetchBookProviders') - this.$store.dispatch('scanners/fetchBookCoverProviders') - } else if (provider.mediaType === 'podcast') { - this.$store.dispatch('scanners/fetchPodcastProviders') - this.$store.dispatch('scanners/fetchPodcastCoverProviders') - } + // Refresh providers cache + this.$store.dispatch('scanners/refreshProviders') }, customMetadataProviderRemoved(provider) { if (!provider?.id) return - // Refetch the appropriate provider types - if (provider.mediaType === 'book') { - this.$store.dispatch('scanners/fetchBookProviders') - this.$store.dispatch('scanners/fetchBookCoverProviders') - } else if (provider.mediaType === 'podcast') { - this.$store.dispatch('scanners/fetchPodcastProviders') - this.$store.dispatch('scanners/fetchPodcastCoverProviders') - } + // Refresh providers cache + this.$store.dispatch('scanners/refreshProviders') }, initializeSocket() { if (this.$root.socket) { @@ -626,12 +614,6 @@ export default { this.$store.dispatch('libraries/load') - // Fetch metadata providers - this.$store.dispatch('scanners/fetchBookProviders') - this.$store.dispatch('scanners/fetchBookCoverProviders') - this.$store.dispatch('scanners/fetchPodcastProviders') - this.$store.dispatch('scanners/fetchPodcastCoverProviders') - this.initLocalStorage() this.checkVersionUpdate() From 538a5065a448336bbf29eccf65101728c1f9c53a Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 18:57:27 +0300 Subject: [PATCH 12/16] Update providers users to fetch providers on demand --- .../modals/BatchQuickMatchModel.vue | 6 ++- client/components/modals/item/tabs/Cover.vue | 2 + client/components/modals/item/tabs/Match.vue | 53 ++++++++++++++----- .../modals/libraries/EditLibrary.vue | 2 + .../modals/libraries/LibrarySettings.vue | 5 -- client/pages/config/index.vue | 2 + client/pages/upload/index.vue | 2 + 7 files changed, 52 insertions(+), 20 deletions(-) diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index 138684b20..f6bcd9728 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -96,6 +96,9 @@ export default { }, methods: { init() { + // Fetch providers when modal is shown + this.$store.dispatch('scanners/fetchProviders') + // If we don't have a set provider (first open of dialog) or we've switched library, set // the selected provider to the current library default provider if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) { @@ -127,8 +130,7 @@ export default { this.show = false }) } - }, - mounted() {} + } } diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index 4ed734b1b..be17f9636 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -438,6 +438,8 @@ export default { mounted() { // Setup socket listeners when component is mounted this.addSocketListeners() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') }, beforeDestroy() { // Cancel any ongoing search when component is destroyed diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 36cdd7a8e..4b92f6cd8 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -2,7 +2,7 @@
-
+
@@ -253,6 +253,7 @@ export default { hasSearched: false, selectedMatch: null, selectedMatchOrig: null, + waitingForProviders: false, selectedMatchUsage: { title: true, subtitle: true, @@ -285,9 +286,19 @@ export default { handler(newVal) { if (newVal) this.init() } + }, + providersLoaded(isLoaded) { + // Complete initialization once providers are loaded + if (isLoaded && this.waitingForProviders) { + this.waitingForProviders = false + this.initProviderAndSearch() + } } }, computed: { + providersLoaded() { + return this.$store.getters['scanners/areProvidersLoaded'] + }, isProcessing: { get() { return this.processing @@ -478,6 +489,24 @@ export default { this.checkboxToggled() }, + initProviderAndSearch() { + // Set provider based on media type + if (this.isPodcast) { + this.provider = 'itunes' + } else { + this.provider = this.getDefaultBookProvider() + } + + // Prefer using ASIN if set and using audible provider + if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) { + this.searchTitle = this.libraryItem.media.metadata.asin + this.searchAuthor = '' + } + + if (this.searchTitle) { + this.submitSearch() + } + }, init() { this.clearSelectedMatch() this.initSelectedMatchUsage() @@ -495,19 +524,13 @@ export default { } this.searchTitle = this.libraryItem.media.metadata.title this.searchAuthor = this.libraryItem.media.metadata.authorName || '' - if (this.isPodcast) this.provider = 'itunes' - else { - this.provider = this.getDefaultBookProvider() - } - // Prefer using ASIN if set and using audible provider - if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) { - this.searchTitle = this.libraryItem.media.metadata.asin - this.searchAuthor = '' - } - - if (this.searchTitle) { - this.submitSearch() + // Wait for providers to be loaded before setting provider and searching + if (this.providersLoaded || this.isPodcast) { + this.waitingForProviders = false + this.initProviderAndSearch() + } else { + this.waitingForProviders = true } }, selectMatch(match) { @@ -637,6 +660,10 @@ export default { this.selectedMatch = null this.selectedMatchOrig = null } + }, + mounted() { + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') } } diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 1e4d3990a..c805f79b0 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -156,6 +156,8 @@ export default { }, mounted() { this.init() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') } } diff --git a/client/components/modals/libraries/LibrarySettings.vue b/client/components/modals/libraries/LibrarySettings.vue index 231cb5ec4..7cfc22017 100644 --- a/client/components/modals/libraries/LibrarySettings.vue +++ b/client/components/modals/libraries/LibrarySettings.vue @@ -104,7 +104,6 @@ export default { }, data() { return { - provider: null, useSquareBookCovers: false, enableWatcher: false, skipMatchingMediaWithAsin: false, @@ -134,10 +133,6 @@ export default { isPodcastLibrary() { return this.mediaType === 'podcast' }, - providers() { - if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.bookProviders - }, maskAsFinishedWhenItems() { return [ { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 099ae9c4e..b8cf3cff2 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -417,6 +417,8 @@ export default { }, mounted() { this.initServerSettings() + // Fetch providers if not already loaded (for cover provider dropdown) + this.$store.dispatch('scanners/fetchProviders') } } diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 84d4d1478..73ebef9c6 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -394,6 +394,8 @@ export default { this.setMetadataProvider() this.setDefaultFolder() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragleave', this.dragleave) window.addEventListener('dragover', this.dragover) From fd593caafc0a7ffc2ce27ae2d649b6163c0c3f67 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 21 Oct 2025 09:38:35 +0300 Subject: [PATCH 13/16] SearchController: simplify query param validation logic --- server/controllers/SearchController.js | 212 ++++++++++--------------- server/utils/index.js | 49 ++++-- 2 files changed, 124 insertions(+), 137 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 72d215f34..9bb6e397d 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -4,7 +4,7 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') -const { isValidASIN, getQueryParamAsString } = require('../utils') +const { isValidASIN, getQueryParamAsString, ValidationError, NotFoundError } = require('../utils') // Provider name mappings for display purposes const providerMap = { @@ -39,73 +39,17 @@ class SearchController { constructor() {} /** - * Validates that multiple parameters are strings - * @param {Object} params - Object with param names as keys and values to validate - * @param {string} methodName - Name of the calling method for logging - * @returns {{valid: boolean, error?: {status: number, message: string}}} - */ - static validateStringParams(params, methodName) { - for (const [key, value] of Object.entries(params)) { - if (typeof value !== 'string') { - Logger.error(`[SearchController] ${methodName}: Invalid ${key} parameter`) - return { - valid: false, - error: { - status: 400, - message: 'Invalid request query params' - } - } - } - } - return { valid: true } - } - - /** - * Validates that a required string parameter exists and is a string - * @param {any} value - Value to validate - * @param {string} paramName - Parameter name for logging - * @param {string} methodName - Name of the calling method for logging - * @returns {{valid: boolean, error?: {status: number, message: string}}} - */ - static validateRequiredString(value, paramName, methodName) { - if (!value || typeof value !== 'string') { - Logger.error(`[SearchController] ${methodName}: Invalid or missing ${paramName}`) - return { - valid: false, - error: { - status: 400, - message: `Invalid or missing ${paramName}` - } - } - } - return { valid: true } - } - - /** - * Validates and fetches a library item by ID + * Fetches a library item by ID * @param {string} id - Library item ID * @param {string} methodName - Name of the calling method for logging - * @returns {Promise<{valid: boolean, libraryItem?: any, error?: {status: number, message: string}}>} + * @returns {Promise} */ - static async fetchAndValidateLibraryItem(id, methodName) { - const validation = SearchController.validateRequiredString(id, 'library item id', methodName) - if (!validation.valid) { - return validation - } - + static async fetchLibraryItem(id) { const libraryItem = await Database.libraryItemModel.getExpandedById(id) if (!libraryItem) { - Logger.error(`[SearchController] ${methodName}: Library item not found with id "${id}"`) - return { - valid: false, - error: { - status: 404, - message: 'Library item not found' - } - } + throw new NotFoundError(`library item "${id}" not found`) } - - return { valid: true, libraryItem } + return libraryItem } /** @@ -139,21 +83,25 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - // Safely extract query parameters, rejecting arrays to prevent type confusion - const provider = getQueryParamAsString(req.query.provider, 'google') - const title = getQueryParamAsString(req.query.title, '') - const author = getQueryParamAsString(req.query.author, '') + try { + const query = req.query + const provider = getQueryParamAsString(query, 'provider', 'google') + const title = getQueryParamAsString(query, 'title', '') + const author = getQueryParamAsString(query, 'author', '') + const id = getQueryParamAsString(query, 'id', '', true) - // Validate string parameters - const validation = SearchController.validateStringParams({ provider, title, author }, 'findBooks') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) + // Fetch library item + const libraryItem = await SearchController.fetchLibraryItem(id) - // Fetch and validate library item - const itemValidation = await SearchController.fetchAndValidateLibraryItem(req.query.id, 'findBooks') - if (!itemValidation.valid) return res.status(itemValidation.error.status).send(itemValidation.error.message) - - const results = await BookFinder.search(itemValidation.libraryItem, provider, title, author) - res.json(results) + const results = await BookFinder.search(libraryItem, provider, title, author) + res.json(results) + } catch (error) { + Logger.error(`[SearchController] findBooks: ${error.message}`) + if (error instanceof ValidationError || error instanceof NotFoundError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** @@ -163,24 +111,24 @@ class SearchController { * @param {Response} res */ async findCovers(req, res) { - const query = req.query - const podcast = query.podcast === '1' || query.podcast === 1 - const title = getQueryParamAsString(query.title, '') - const author = getQueryParamAsString(query.author, '') - const provider = getQueryParamAsString(query.provider, 'google') + try { + const query = req.query + const podcast = query.podcast === '1' || query.podcast === 1 + const title = getQueryParamAsString(query, 'title', '', true) + const author = getQueryParamAsString(query, 'author', '') + const provider = getQueryParamAsString(query, 'provider', 'google') - // Validate required title - const titleValidation = SearchController.validateRequiredString(title, 'title', 'findCovers') - if (!titleValidation.valid) return res.status(titleValidation.error.status).send(titleValidation.error.message) - - // Validate other string parameters - const validation = SearchController.validateStringParams({ author, provider }, 'findCovers') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - - let results = null - if (podcast) results = await PodcastFinder.findCovers(title) - else results = await BookFinder.findCovers(provider, title, author) - res.json({ results }) + let results = null + if (podcast) results = await PodcastFinder.findCovers(title) + else results = await BookFinder.findCovers(provider, title, author) + res.json({ results }) + } catch (error) { + Logger.error(`[SearchController] findCovers: ${error.message}`) + if (error instanceof ValidationError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** @@ -191,36 +139,42 @@ class SearchController { * @param {Response} res */ async findPodcasts(req, res) { - const term = getQueryParamAsString(req.query.term) - const country = getQueryParamAsString(req.query.country, 'us') + try { + const query = req.query + const term = getQueryParamAsString(query, 'term', '', true) + const country = getQueryParamAsString(query, 'country', 'us') - // Validate required term - const termValidation = SearchController.validateRequiredString(term, 'term', 'findPodcasts') - if (!termValidation.valid) return res.status(termValidation.error.status).send(termValidation.error.message) - - // Validate country parameter - const validation = SearchController.validateStringParams({ country }, 'findPodcasts') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - - const results = await PodcastFinder.search(term, { country }) - res.json(results) + const results = await PodcastFinder.search(term, { country }) + res.json(results) + } catch (error) { + Logger.error(`[SearchController] findPodcasts: ${error.message}`) + if (error instanceof ValidationError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** * GET: /api/search/authors + * Note: This endpoint is not currently used in the web client. * * @param {RequestWithUser} req * @param {Response} res */ async findAuthor(req, res) { - const query = getQueryParamAsString(req.query.q) + try { + const query = getQueryParamAsString(req.query, 'q', '', true) - // Validate query parameter - const validation = SearchController.validateRequiredString(query, 'query', 'findAuthor') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - - const author = await AuthorFinder.findAuthorByName(query) - res.json(author) + const author = await AuthorFinder.findAuthorByName(query) + res.json(author) + } catch (error) { + Logger.error(`[SearchController] findAuthor: ${error.message}`) + if (error instanceof ValidationError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** @@ -230,24 +184,30 @@ class SearchController { * @param {Response} res */ async findChapters(req, res) { - const asin = getQueryParamAsString(req.query.asin) - const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() + try { + const query = req.query + const asin = getQueryParamAsString(query, 'asin', '', true) + const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() - // Validate ASIN parameter - const asinValidation = SearchController.validateRequiredString(asin, 'asin', 'findChapters') - if (!asinValidation.valid) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid') - if (!isValidASIN(asin.toUpperCase())) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) - - // Validate region parameter - const validation = SearchController.validateStringParams({ region }, 'findChapters') - if (!validation.valid) res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) - - const chapterData = await BookFinder.findChapters(asin, region) - if (!chapterData) { - return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) + const chapterData = await BookFinder.findChapters(asin, region) + if (!chapterData) { + return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) + } + res.json(chapterData) + } catch (error) { + Logger.error(`[SearchController] findChapters: ${error.message}`) + if (error instanceof ValidationError) { + if (error.paramName === 'asin') { + return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + } + if (error.paramName === 'region') { + return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) + } + } + return res.status(500).send('Internal server error') } - res.json(chapterData) } /** diff --git a/server/utils/index.js b/server/utils/index.js index 0661d14f8..c7700a783 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -278,29 +278,56 @@ module.exports.timestampToSeconds = (timestamp) => { return null } +class ValidationError extends Error { + constructor(paramName, message, status = 400) { + super(`Query parameter "${paramName}" ${message}`) + this.name = 'ValidationError' + this.paramName = paramName + this.status = status + } +} +module.exports.ValidationError = ValidationError + +class NotFoundError extends Error { + constructor(message, status = 404) { + super(message) + this.name = 'NotFoundError' + this.status = status + } +} +module.exports.NotFoundError = NotFoundError + /** * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion * Express query parameters can be arrays if the same parameter appears multiple times * @example ?author=Smith => "Smith" - * @example ?author=Smith&author=Jones => null (array detected) + * @example ?author=Smith&author=Jones => throws error * - * @param {any} value - Query parameter value + * @param {Object} query - Query object + * @param {string} paramName - Parameter name * @param {string} defaultValue - Default value if undefined/null + * @param {boolean} required - Whether the parameter is required * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks) - * @returns {string|null} String value or null if invalid (array or too long) + * @returns {string} String value + * @throws {ValidationError} If value is an array + * @throws {ValidationError} If value is too long + * @throws {ValidationError} If value is required but not provided */ -module.exports.getQueryParamAsString = (value, defaultValue = '', maxLength = 1000) => { +module.exports.getQueryParamAsString = (query, paramName, defaultValue = '', required = false, maxLength = 1000) => { + const value = query[paramName] + if (value === undefined || value === null) { + if (required) { + throw new ValidationError(paramName, 'is required') + } + return defaultValue + } // Explicitly reject arrays to prevent type confusion if (Array.isArray(value)) { - return null - } - // Return default for undefined/null - if (value == null) { - return defaultValue + throw new ValidationError(paramName, 'is an array') } // Reject excessively long strings to prevent ReDoS attacks if (typeof value === 'string' && value.length > maxLength) { - return null + throw new ValidationError(paramName, 'is too long') } - return value + return String(value) } From 57c7b123f05d416347829edf13ef10a798bd0fd5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 21 Oct 2025 11:00:29 +0300 Subject: [PATCH 14/16] Fix codeQL error: Return json error object --- server/controllers/SearchController.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 9bb6e397d..5a16229cd 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -98,9 +98,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findBooks: ${error.message}`) if (error instanceof ValidationError || error instanceof NotFoundError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -125,9 +125,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findCovers: ${error.message}`) if (error instanceof ValidationError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -149,9 +149,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findPodcasts: ${error.message}`) if (error instanceof ValidationError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -171,9 +171,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findAuthor: ${error.message}`) if (error instanceof ValidationError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -206,7 +206,7 @@ class SearchController { return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) } } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } From 6db6b862e6c5342564639e741ec01ae32deed8fa Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 21 Oct 2025 17:33:53 +0300 Subject: [PATCH 15/16] Upgrade codeql-actions to v3 --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e5f4bced..809563018 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' From e684a8dc43cedcb5f7af003b54c09bfa2bffc3d4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 21 Oct 2025 17:22:10 -0500 Subject: [PATCH 16/16] Update JSDocs & auto-formatting of PodcastFinder --- server/controllers/SearchController.js | 2 +- server/finders/PodcastFinder.js | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 5a16229cd..f6f0ba475 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -42,7 +42,7 @@ class SearchController { * Fetches a library item by ID * @param {string} id - Library item ID * @param {string} methodName - Name of the calling method for logging - * @returns {Promise} + * @returns {Promise} */ static async fetchLibraryItem(id) { const libraryItem = await Database.libraryItemModel.getExpandedById(id) diff --git a/server/finders/PodcastFinder.js b/server/finders/PodcastFinder.js index abaf02ac6..40d6a5a00 100644 --- a/server/finders/PodcastFinder.js +++ b/server/finders/PodcastFinder.js @@ -7,9 +7,9 @@ class PodcastFinder { } /** - * - * @param {string} term - * @param {{country:string}} options + * + * @param {string} term + * @param {{country:string}} options * @returns {Promise} */ async search(term, options = {}) { @@ -20,12 +20,16 @@ class PodcastFinder { return results } + /** + * @param {string} term + * @returns {Promise} + */ async findCovers(term) { if (!term) return null Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`) - var results = await this.iTunesApi.searchPodcasts(term) + const results = await this.iTunesApi.searchPodcasts(term) if (!results) return [] - return results.map(r => r.cover).filter(r => r) + return results.map((r) => r.cover).filter((r) => r) } } -module.exports = new PodcastFinder() \ No newline at end of file +module.exports = new PodcastFinder()