+
@@ -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)
+}