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}}' diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index 8bea68faf..f6bcd9728 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' @@ -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 b404a9abb..be17f9636 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 @@ -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 3faa26b27..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 @@ -319,7 +330,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 @@ -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 083fc5766..c805f79b0 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: { @@ -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 d3b40de95..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.providers - }, maskAsFinishedWhenItems() { return [ { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 4b9729248..75753b214 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -371,11 +371,13 @@ export default { }, customMetadataProviderAdded(provider) { if (!provider?.id) return - this.$store.commit('scanners/addCustomMetadataProvider', provider) + // Refresh providers cache + this.$store.dispatch('scanners/refreshProviders') }, customMetadataProviderRemoved(provider) { if (!provider?.id) return - this.$store.commit('scanners/removeCustomMetadataProvider', provider) + // Refresh providers cache + this.$store.dispatch('scanners/refreshProviders') }, initializeSocket() { if (this.$root.socket) { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 3d030bb32..b8cf3cff2 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 @@ -416,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 eef05b608..73ebef9c6 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 @@ -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) diff --git a/client/store/libraries.js b/client/store/libraries.js index 115fb53bf..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 }) @@ -131,8 +130,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..ccf7d9249 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -1,126 +1,60 @@ 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: [], + providersLoaded: false }) 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) + }, + areProvidersLoaded: (state) => state.providersLoaded +} + +export const actions = { + async fetchProviders({ commit, state }) { + // Only fetch if not already loaded + if (state.providersLoaded) { + return + } + + try { + const response = await this.$axios.$get('/api/search/providers') + if (response?.providers) { + commit('setAllProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch providers', error) + } + }, + async refreshProviders({ commit, state }) { + // if providers are not loaded, do nothing - they will be fetched when required ( + if (!state.providersLoaded) { + return + } + + try { + const response = await this.$axios.$get('/api/search/providers') + if (response?.providers) { + commit('setAllProviders', response.providers) + } + } catch (error) { + console.error('Failed to refresh providers', error) + } } } -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 - }) - } - }, - 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) - } - }, - 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 - } + 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 } -} \ No newline at end of file +} 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() }) } diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index bb3382f71..f6f0ba475 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -4,7 +4,29 @@ 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, ValidationError, NotFoundError } = 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 @@ -16,6 +38,44 @@ const { isValidASIN } = require('../utils') class SearchController { constructor() {} + /** + * Fetches a library item by ID + * @param {string} id - Library item ID + * @param {string} methodName - Name of the calling method for logging + * @returns {Promise} + */ + static async fetchLibraryItem(id) { + const libraryItem = await Database.libraryItemModel.getExpandedById(id) + if (!libraryItem) { + throw new NotFoundError(`library item "${id}" not found`) + } + return 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,19 +83,25 @@ 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 || '' + 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) - 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') + // Fetch library item + const libraryItem = await SearchController.fetchLibraryItem(id) + + 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).json({ error: error.message }) + } + return res.status(500).json({ error: 'Internal server error' }) } - - const results = await BookFinder.search(libraryItem, provider, title, author) - res.json(results) } /** @@ -45,20 +111,24 @@ class SearchController { * @param {Response} res */ async findCovers(req, res) { - const query = req.query - const podcast = query.podcast == 1 + 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') - if (!query.title || typeof query.title !== 'string') { - Logger.error(`[SearchController] findCovers: Invalid title sent in query`) - return res.sendStatus(400) + 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).json({ error: error.message }) + } + return res.status(500).json({ error: 'Internal server error' }) } - - 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 - }) } /** @@ -69,34 +139,42 @@ class SearchController { * @param {Response} res */ 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') - } + try { + const query = req.query + const term = getQueryParamAsString(query, 'term', '', true) + const country = getQueryParamAsString(query, 'country', 'us') - 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).json({ error: error.message }) + } + return res.status(500).json({ error: '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 = req.query.q - if (!query || typeof query !== 'string') { - Logger.error(`[SearchController] findAuthor: Invalid query param`) - return res.status(400).send('Invalid query param') - } + try { + const query = getQueryParamAsString(req.query, 'q', '', true) - 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).json({ error: error.message }) + } + return res.status(500).json({ error: 'Internal server error' }) + } } /** @@ -106,16 +184,55 @@ class SearchController { * @param {Response} res */ async findChapters(req, res) { - const asin = req.query.asin - if (!isValidASIN(asin.toUpperCase())) { - return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + try { + const query = req.query + const asin = getQueryParamAsString(query, 'asin', '', true) + const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() + + if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid') + + 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).json({ error: 'Internal server error' }) } - const region = (req.query.region || 'us').toLowerCase() - const chapterData = await BookFinder.findChapters(asin, region) - if (!chapterData) { - return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) + } + + /** + * GET: /api/search/providers + * Get all available metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getAllProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll() + + const customBookProviders = customProviders.filter((p) => p.mediaType === 'book') + const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast') + + const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers') + + // 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(chapterData) + + res.json({ providers }) } } module.exports = new SearchController() diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index a6a6b07e6..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,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 (input length validated at entry point) + 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 +674,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 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 // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") cleaned = cleaned.replace(/'/g, '') 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() 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) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..db04bf5ec 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -283,6 +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', SearchController.getAllProviders.bind(this)) // // Cache Routes (Admin and up) diff --git a/server/utils/index.js b/server/utils/index.js index 369620276..c7700a783 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -277,3 +277,57 @@ 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 => throws error + * + * @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} 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 = (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)) { + throw new ValidationError(paramName, 'is an array') + } + // Reject excessively long strings to prevent ReDoS attacks + if (typeof value === 'string' && value.length > maxLength) { + throw new ValidationError(paramName, 'is too long') + } + return String(value) +}