From 8b3f67b7c942c8f3bd8bcbc7e03a52fb0ed9dd8a Mon Sep 17 00:00:00 2001 From: Kevin Gatera Date: Mon, 2 Mar 2026 18:20:58 -0500 Subject: [PATCH] guard numeric casts for postgres text fields --- server/models/Series.js | 3 +- .../utils/queries/libraryItemsBookFilters.js | 34 +++++++------------ server/utils/sqlDialectHelpers.js | 18 +++++++++- test/server/utils/sqlDialectHelpers.test.js | 12 ++++++- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/server/models/Series.js b/server/models/Series.js index 28450a010..192378b7d 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -1,6 +1,7 @@ const { DataTypes, Model, where, fn, col, literal } = require('sequelize') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index') +const { safeTextToDoubleExpression } = require('../utils/sqlDialectHelpers') class Series extends Model { constructor(values, options) { @@ -158,7 +159,7 @@ class Series extends Model { } } ], - order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]] + order: [[literal(`${safeTextToDoubleExpression(this.sequelize.getDialect() === 'postgres' ? '"bookSeries"."sequence"' : '`bookSeries.sequence`', this.sequelize)} ASC NULLS LAST`)]] }) } diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 029448529..993946c71 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -6,7 +6,7 @@ const authorFilters = require('./authorFilters') const ShareManager = require('../../managers/ShareManager') const { profile } = require('../profiler') const stringifySequelizeQuery = require('../stringifySequelizeQuery') -const { booleanLiteral, noCaseSortExpression, coalesceFunctionName, jsonArrayContainsAny, jsonArrayContainsValue, jsonArrayExpand } = require('../sqlDialectHelpers') +const { booleanLiteral, noCaseSortExpression, coalesceFunctionName, jsonArrayContainsAny, jsonArrayContainsValue, jsonArrayExpand, safeTextToDoubleExpression, safeTextToIntegerExpression } = require('../sqlDialectHelpers') const countCache = new Map() module.exports = { @@ -237,7 +237,7 @@ module.exports = { } else if (group === 'publishedDecades') { const startYear = parseInt(value) const endYear = parseInt(value, 10) + 9 - mediaWhere = Sequelize.where(Sequelize.literal('CAST(publishedYear AS INTEGER)'), { + mediaWhere = Sequelize.where(Sequelize.literal(safeTextToIntegerExpression('publishedYear', Database.sequelize)), { [Sequelize.Op.between]: [startYear, endYear] }) } @@ -274,7 +274,7 @@ module.exports = { } else if (sortBy === 'media.duration') { return [['duration', dir]] } else if (sortBy === 'media.metadata.publishedYear') { - return [[Sequelize.literal('CAST(book.publishedYear AS INTEGER)'), dir]] + return [[Sequelize.literal(safeTextToIntegerExpression('book.publishedYear', Database.sequelize)), dir]] } else if (sortBy === 'media.metadata.authorNameLF') { // Sort by author name last first, secondary sort by title return [[Sequelize.literal(noCaseSortExpression('libraryItem.authorNamesLastFirst', Database.sequelize)), dir], getTitleOrder()] @@ -288,10 +288,8 @@ module.exports = { return [getTitleOrder()] } else if (sortBy === 'sequence') { const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' - if (Database.sequelize.getDialect() === 'postgres') { - return [[Sequelize.literal(`CAST("series.bookSeries"."sequence" AS DOUBLE PRECISION) ${nullDir}`)]] - } - return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]] + const sequenceColumn = Database.sequelize.getDialect() === 'postgres' ? '"series.bookSeries"."sequence"' : '`series.bookSeries.sequence`' + return [[Sequelize.literal(`${safeTextToDoubleExpression(sequenceColumn, Database.sequelize)} ${nullDir}`)]] } else if (sortBy === 'progress') { return [[Sequelize.literal(`mediaProgresses.updatedAt ${dir} NULLS LAST`)]] } else if (sortBy === 'progress.createdAt') { @@ -331,11 +329,7 @@ module.exports = { } ], order: [ - Sequelize.literal( - Database.sequelize.getDialect() === 'postgres' - ? 'CAST("books.bookSeries"."sequence" AS DOUBLE PRECISION) ASC NULLS LAST' - : 'CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST' - ) + Sequelize.literal(`${safeTextToDoubleExpression(Database.sequelize.getDialect() === 'postgres' ? '"books.bookSeries"."sequence"' : '`books.bookSeries.sequence`', Database.sequelize)} ASC NULLS LAST`) ] }) const bookSeriesToInclude = [] @@ -529,11 +523,7 @@ module.exports = { if (sortBy !== 'sequence') { // Secondary sort by sequence sortOrder.push([ - Sequelize.literal( - Database.sequelize.getDialect() === 'postgres' - ? 'CAST("series.bookSeries"."sequence" AS DOUBLE PRECISION) ASC NULLS LAST' - : 'CAST(`series.bookSeries.sequence` AS FLOAT) ASC NULLS LAST' - ) + Sequelize.literal(`${safeTextToDoubleExpression(Database.sequelize.getDialect() === 'postgres' ? '"series.bookSeries"."sequence"' : '`series.bookSeries.sequence`', Database.sequelize)} ASC NULLS LAST`) ]) } } else if (filterGroup === 'issues') { @@ -755,11 +745,11 @@ module.exports = { let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = ${booleanLiteral(false, Database.sequelize)} OR mp.isFinished IS NULL)` if (library.settings.onlyShowLaterBooksInContinueSeries) { - const castType = Database.sequelize.getDialect() === 'postgres' ? 'DOUBLE PRECISION' : 'FLOAT' - const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as ${castType}) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = ${booleanLiteral(true, Database.sequelize)} AND mp.userId = :userId AND bs.seriesId = series.id)` + const safeSequenceExpr = safeTextToDoubleExpression('bs.sequence', Database.sequelize) + const maxSequenceQuery = `(SELECT max(${safeSequenceExpr}) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = ${booleanLiteral(true, Database.sequelize)} AND mp.userId = :userId AND bs.seriesId = series.id)` includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence']) - booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as ${castType}) > ${maxSequenceQuery}` + booksNotFinishedQuery = booksNotFinishedQuery + ` AND ${safeSequenceExpr} > ${maxSequenceQuery}` } const { rows: series, count } = await Database.seriesModel.findAndCountAll({ @@ -794,7 +784,7 @@ module.exports = { attributes: ['bookId', 'sequence'], separate: true, subQuery: false, - order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]], + order: [[Sequelize.literal(`${safeTextToDoubleExpression('sequence', Database.sequelize)} ASC NULLS LAST`)]], where: { '$book.mediaProgresses.isFinished$': { [Sequelize.Op.or]: [null, false] @@ -907,7 +897,7 @@ module.exports = { model: Database.bookModel, where: userPermissionBookWhere.bookWhere }, - order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]], + order: [[Sequelize.literal(`${safeTextToDoubleExpression('sequence', Database.sequelize)} ASC NULLS LAST`)]], limit: 1 }, subQuery: false, diff --git a/server/utils/sqlDialectHelpers.js b/server/utils/sqlDialectHelpers.js index 36538bcb1..7f6f93118 100644 --- a/server/utils/sqlDialectHelpers.js +++ b/server/utils/sqlDialectHelpers.js @@ -61,6 +61,20 @@ function jsonPathNumber(columnExpression, pathSegments, sequelize) { return `json_extract(${columnExpression}, '$.${[].concat(pathSegments).join('.')}')` } +function safeTextToDoubleExpression(columnExpression, sequelize) { + if (isPostgres(sequelize)) { + return `CASE WHEN BTRIM(${columnExpression}) ~ '^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)$' THEN BTRIM(${columnExpression})::double precision ELSE NULL END` + } + return `CAST(${columnExpression} AS FLOAT)` +} + +function safeTextToIntegerExpression(columnExpression, sequelize) { + if (isPostgres(sequelize)) { + return `CASE WHEN BTRIM(${columnExpression}) ~ '^[+-]?\\d+$' THEN BTRIM(${columnExpression})::integer ELSE NULL END` + } + return `CAST(${columnExpression} AS INTEGER)` +} + module.exports = { getDialect, isPostgres, @@ -71,5 +85,7 @@ module.exports = { jsonArrayContainsValue, jsonArrayExpand, jsonPathText, - jsonPathNumber + jsonPathNumber, + safeTextToDoubleExpression, + safeTextToIntegerExpression } diff --git a/test/server/utils/sqlDialectHelpers.test.js b/test/server/utils/sqlDialectHelpers.test.js index 006b02364..82d9154d8 100644 --- a/test/server/utils/sqlDialectHelpers.test.js +++ b/test/server/utils/sqlDialectHelpers.test.js @@ -8,7 +8,9 @@ const { jsonArrayContainsValue, jsonArrayExpand, jsonPathText, - jsonPathNumber + jsonPathNumber, + safeTextToDoubleExpression, + safeTextToIntegerExpression } = require('../../../server/utils/sqlDialectHelpers') const sqlite = { @@ -55,4 +57,12 @@ describe('sqlDialectHelpers', () => { expect(jsonPathNumber('payload', ['duration'], sqlite)).to.equal("json_extract(payload, '$.duration')") expect(jsonPathNumber('payload', ['duration'], postgres)).to.equal("NULLIF(payload::jsonb #>> '{duration}', '')::double precision") }) + + it('should generate safe numeric cast expressions for sequence-like text', () => { + expect(safeTextToDoubleExpression('sequence', sqlite)).to.equal('CAST(sequence AS FLOAT)') + expect(safeTextToDoubleExpression('"bookSeries"."sequence"', postgres)).to.equal("CASE WHEN BTRIM(\"bookSeries\".\"sequence\") ~ '^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)$' THEN BTRIM(\"bookSeries\".\"sequence\")::double precision ELSE NULL END") + + expect(safeTextToIntegerExpression('publishedYear', sqlite)).to.equal('CAST(publishedYear AS INTEGER)') + expect(safeTextToIntegerExpression('book.publishedYear', postgres)).to.equal("CASE WHEN BTRIM(book.publishedYear) ~ '^[+-]?\\d+$' THEN BTRIM(book.publishedYear)::integer ELSE NULL END") + }) })