From a018374d26d2ec4a7e4bbebc03eda64df27d6e62 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 9 Jun 2024 13:43:03 -0500 Subject: [PATCH] Update:Validate ASIN for author, chapter and match requests --- server/controllers/AuthorController.js | 4 +-- server/controllers/SearchController.js | 18 ++++++---- server/providers/Audible.js | 15 ++------ server/providers/Audnexus.js | 31 ++++++++++++---- server/utils/index.js | 49 +++++++++++++++++--------- 5 files changed, 72 insertions(+), 45 deletions(-) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 52f2aa01..79f48dbd 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -9,7 +9,7 @@ const CacheManager = require('../managers/CacheManager') const CoverManager = require('../managers/CoverManager') const AuthorFinder = require('../finders/AuthorFinder') -const { reqSupportsWebp } = require('../utils/index') +const { reqSupportsWebp, isValidASIN } = require('../utils/index') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -252,7 +252,7 @@ class AuthorController { async match(req, res) { let authorData = null const region = req.body.region || 'us' - if (req.body.asin) { + if (req.body.asin && isValidASIN(req.body.asin.toUpperCase?.())) { authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region) } else { authorData = await AuthorFinder.findAuthorByName(req.body.q, region) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 213d23e1..b0aebb31 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -1,12 +1,13 @@ -const Logger = require("../Logger") +const Logger = require('../Logger') const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const MusicFinder = require('../finders/MusicFinder') -const Database = require("../Database") +const Database = require('../Database') +const { isValidASIN } = require('../utils') class SearchController { - constructor() { } + constructor() {} async findBooks(req, res) { const id = req.query.id @@ -37,9 +38,9 @@ class SearchController { /** * Find podcast RSS feeds given a term - * - * @param {import('express').Request} req - * @param {import('express').Response} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async findPodcasts(req, res) { const term = req.query.term @@ -63,6 +64,9 @@ class SearchController { async findChapters(req, res) { const asin = req.query.asin + if (!isValidASIN(asin.toUpperCase())) { + return res.json({ error: 'Invalid ASIN' }) + } const region = (req.query.region || 'us').toLowerCase() const chapterData = await BookFinder.findChapters(asin, region) if (!chapterData) { @@ -78,4 +82,4 @@ class SearchController { }) } } -module.exports = new SearchController() \ No newline at end of file +module.exports = new SearchController() diff --git a/server/providers/Audible.js b/server/providers/Audible.js index 96c6cccb..76225613 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -1,6 +1,7 @@ const axios = require('axios').default const htmlSanitizer = require('../utils/htmlSanitizer') const Logger = require('../Logger') +const { isValidASIN } = require('../utils/index') class Audible { #responseTimeout = 30000 @@ -81,16 +82,6 @@ class Audible { } } - /** - * Test if a search title matches an ASIN. Supports lowercase letters - * - * @param {string} title - * @returns {boolean} - */ - isProbablyAsin(title) { - return /^[0-9A-Za-z]{10}$/.test(title) - } - /** * * @param {string} asin @@ -137,11 +128,11 @@ class Audible { if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout let items - if (asin) { + if (asin && isValidASIN(asin.toUpperCase())) { items = [await this.asinSearch(asin, region, timeout)] } - if (!items && this.isProbablyAsin(title)) { + if (!items && isValidASIN(title.toUpperCase())) { items = [await this.asinSearch(title, region, timeout)] } diff --git a/server/providers/Audnexus.js b/server/providers/Audnexus.js index e34402e4..60762ede 100644 --- a/server/providers/Audnexus.js +++ b/server/providers/Audnexus.js @@ -1,7 +1,8 @@ const axios = require('axios').default -const { levenshteinDistance } = require('../utils/index') -const Logger = require('../Logger') const Throttle = require('p-throttle') +const Logger = require('../Logger') +const { levenshteinDistance } = require('../utils/index') +const { isValidASIN } = require('../utils/index') /** * @typedef AuthorSearchObj @@ -66,13 +67,19 @@ class Audnexus { * @returns {Promise} */ authorRequest(asin, region) { - asin = encodeURIComponent(asin) - const regionQuery = region ? `?region=${region}` : '' - const authorRequestUrl = `${this.baseUrl}/authors/${asin}${regionQuery}` + if (!isValidASIN(asin?.toUpperCase?.())) { + Logger.error(`[Audnexus] Invalid ASIN ${asin}`) + return null + } + + asin = encodeURIComponent(asin.toUpperCase()) + + const authorRequestUrl = new URL(`${this.baseUrl}/authors/${asin}`) + if (region) authorRequestUrl.searchParams.set('region', region) Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`) - return this._processRequest(this.limiter(() => axios.get(authorRequestUrl))) + return this._processRequest(this.limiter(() => axios.get(authorRequestUrl.toString()))) .then((res) => res.data) .catch((error) => { Logger.error(`[Audnexus] Author request failed for ${asin}`, error) @@ -135,10 +142,20 @@ class Audnexus { } } + /** + * + * @param {string} asin + * @param {string} region + * @returns {Promise} + */ getChaptersByASIN(asin, region) { Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}/${region}`) - return this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/books/${asin}/chapters?region=${region}`))) + asin = encodeURIComponent(asin.toUpperCase()) + const chaptersRequestUrl = new URL(`${this.baseUrl}/books/${asin}/chapters`) + if (region) chaptersRequestUrl.searchParams.set('region', region) + + return this._processRequest(this.limiter(() => axios.get(chaptersRequestUrl.toString()))) .then((res) => res.data) .catch((error) => { Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error) diff --git a/server/utils/index.js b/server/utils/index.js index 6a89621b..14f297c1 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -1,7 +1,7 @@ const Path = require('path') const uuid = require('uuid') const Logger = require('../Logger') -const { parseString } = require("xml2js") +const { parseString } = require('xml2js') const areEquivalent = require('./areEquivalent') const levenshteinDistance = (str1, str2, caseSensitive = false) => { @@ -11,8 +11,9 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { str1 = str1.toLowerCase() str2 = str2.toLowerCase() } - const track = Array(str2.length + 1).fill(null).map(() => - Array(str1.length + 1).fill(null)) + const track = Array(str2.length + 1) + .fill(null) + .map(() => Array(str1.length + 1).fill(null)) for (let i = 0; i <= str1.length; i += 1) { track[0][i] = i } @@ -25,7 +26,7 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { track[j][i] = Math.min( track[j][i - 1] + 1, // deletion track[j - 1][i] + 1, // insertion - track[j - 1][i - 1] + indicator, // substitution + track[j - 1][i - 1] + indicator // substitution ) } } @@ -138,7 +139,10 @@ module.exports.toNumber = (val, fallback = 0) => { module.exports.cleanStringForSearch = (str) => { if (!str) return '' // Remove ' . ` " , - return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim() + return str + .toLowerCase() + .replace(/[\'\.\`\",]/g, '') + .trim() } const getTitleParts = (title) => { @@ -156,7 +160,7 @@ const getTitleParts = (title) => { /** * Remove sortingPrefixes from title * @example "The Good Book" => "Good Book" - * @param {string} title + * @param {string} title * @returns {string} */ module.exports.getTitleIgnorePrefix = (title) => { @@ -164,9 +168,9 @@ module.exports.getTitleIgnorePrefix = (title) => { } /** - * Put sorting prefix at the end of title + * Put sorting prefix at the end of title * @example "The Good Book" => "Good Book, The" - * @param {string} title + * @param {string} title * @returns {string} */ module.exports.getTitlePrefixAtEnd = (title) => { @@ -178,8 +182,8 @@ module.exports.getTitlePrefixAtEnd = (title) => { * to lower case for only ascii characters * used to handle sqlite that doesnt support unicode lower * @see https://github.com/advplyr/audiobookshelf/issues/2187 - * - * @param {string} str + * + * @param {string} str * @returns {string} */ module.exports.asciiOnlyToLowerCase = (str) => { @@ -200,8 +204,8 @@ module.exports.asciiOnlyToLowerCase = (str) => { /** * Escape string used in RegExp * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - * - * @param {string} str + * + * @param {string} str * @returns {string} */ module.exports.escapeRegExp = (str) => { @@ -211,8 +215,8 @@ module.exports.escapeRegExp = (str) => { /** * Validate url string with URL class - * - * @param {string} rawUrl + * + * @param {string} rawUrl * @returns {string} null if invalid */ module.exports.validateUrl = (rawUrl) => { @@ -227,11 +231,22 @@ module.exports.validateUrl = (rawUrl) => { /** * Check if a string is a valid UUID - * - * @param {string} str + * + * @param {string} str * @returns {boolean} */ module.exports.isUUID = (str) => { if (!str || typeof str !== 'string') return false return uuid.validate(str) -} \ No newline at end of file +} + +/** + * Check if a string is a valid ASIN + * + * @param {string} str + * @returns {boolean} + */ +module.exports.isValidASIN = (str) => { + if (!str || typeof str !== 'string') return false + return /^[A-Z0-9]{10}$/.test(str) +}