Merge branch 'master' into token_refresh_race_condition

This commit is contained in:
advplyr 2026-04-30 15:59:22 -05:00
commit 379f6c716a
91 changed files with 4263 additions and 723 deletions

View file

@ -258,6 +258,13 @@ class TokenManager {
}
const user = await Database.userModel.getUserById(apiKey.userId)
if (!user?.isActive) {
// deny login
done(null, null)
return
}
done(null, user)
} else {
// JWT based authentication

View file

@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@ -113,7 +113,7 @@ class AuthorController {
payload.lastFirst = Database.authorModel.getLastFirst(payload.name)
}
// Check if author name matches another author and merge the authors
// Check if author name matches another author in the same library and merge the authors
let existingAuthor = null
if (authorNameUpdate) {
existingAuthor = await Database.authorModel.findOne({
@ -121,7 +121,8 @@ class AuthorController {
id: {
[sequelize.Op.not]: req.author.id
},
name: payload.name
name: payload.name,
libraryId: req.author.libraryId
}
})
}
@ -411,8 +412,8 @@ class AuthorController {
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
}
return CacheManager.handleAuthorCache(res, authorId, options)
}

View file

@ -3,6 +3,7 @@ const Sequelize = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const htmlSanitizer = require('../utils/htmlSanitizer')
const RssFeedManager = require('../managers/RssFeedManager')
@ -31,13 +32,19 @@ class CollectionController {
async create(req, res) {
const reqBody = req.body || {}
const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
// Validation
if (!reqBody.name || !reqBody.libraryId) {
if (!nameCleaned || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description')
}
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to create collection in inaccessible library ${reqBody.libraryId}`)
return res.sendStatus(403)
}
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books')
@ -65,7 +72,7 @@ class CollectionController {
newCollection = await Database.collectionModel.create(
{
libraryId: reqBody.libraryId,
name: reqBody.name,
name: nameCleaned,
description: reqBody.description || null
},
{ transaction }
@ -106,8 +113,9 @@ class CollectionController {
*/
async findAll(req, res) {
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId))
res.json({
collections: collectionsExpanded
collections: accessibleCollections
})
}
@ -145,9 +153,12 @@ class CollectionController {
collectionUpdatePayload.description = req.body.description
wasUpdated = true
}
if (req.body.name !== undefined && req.body.name !== req.collection.name) {
collectionUpdatePayload.name = req.body.name
wasUpdated = true
if (req.body.name !== undefined && typeof req.body.name === 'string') {
const nameCleaned = htmlSanitizer.stripAllTags(req.body.name)
if (nameCleaned !== req.collection.name) {
collectionUpdatePayload.name = nameCleaned
wasUpdated = true
}
}
if (wasUpdated) {
@ -425,6 +436,10 @@ class CollectionController {
if (!collection) {
return res.status(404).send('Collection not found')
}
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to access collection ${collection.id} in inaccessible library ${collection.libraryId}`)
return res.status(404).send('Collection not found')
}
req.collection = collection
}

View file

@ -117,7 +117,7 @@ class FileSystemController {
filepath = fileUtils.filePathToPOSIX(filepath)
// Ensure filepath is inside library folder (prevents directory traversal)
if (!filepath.startsWith(libraryFolder.path)) {
if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) {
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
return res.sendStatus(400)
}

View file

@ -462,7 +462,7 @@ class LibraryController {
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
if (authorIds.length) {
@ -563,7 +563,7 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId)
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
// Set PlaybackSessions libraryId to null
@ -714,7 +714,7 @@ class LibraryController {
}
}
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
}
if (authorIds.length) {
@ -1435,10 +1435,15 @@ class LibraryController {
const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'libraryId', 'path', 'isFile'],
where: {
id: itemIds
id: itemIds,
libraryId: req.library.id
}
})
if (libraryItems.length < itemIds.length) {
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
}
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
const filename = `LibraryItems-${Date.now()}.zip`

View file

@ -1,13 +1,14 @@
const { Request, Response, NextFunction } = require('express')
const Path = require('path')
const fs = require('../libs/fsExtra')
const cron = require('../libs/nodeCron')
const uaParserJs = require('../libs/uaParser')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index')
const { reqSupportsWebp, clampPositiveInt } = require('../utils/index')
const { ScanResult, AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/
/**
* Enforce per-item access for batch item routes
*
* @param {RequestWithUser} req
* @param {Response} res
* @param {import('../models/LibraryItem')[]} libraryItems
* @returns {boolean} true if the user may access every item; false if 403 was sent
*/
function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) {
for (const libraryItem of libraryItems) {
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
res.sendStatus(403)
return false
}
}
return true
}
class LibraryItemController {
constructor() {}
@ -111,7 +130,7 @@ class LibraryItemController {
}
}
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@ -202,6 +221,11 @@ class LibraryItemController {
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true
}
if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
return res.status(400).send('Invalid auto download schedule cron expression')
}
}
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
@ -398,8 +422,8 @@ class LibraryItemController {
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
}
return CacheManager.handleCoverCache(res, libraryItemId, options)
}
@ -547,7 +571,13 @@ class LibraryItemController {
return res.sendStatus(404)
}
// Ensure user has permission to delete these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
return
}
const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
@ -565,7 +595,7 @@ class LibraryItemController {
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId)
if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => {
@ -581,6 +611,7 @@ class LibraryItemController {
}
await Database.resetLibraryIssuesFilterData(libraryId)
res.sendStatus(200)
}
@ -593,6 +624,11 @@ class LibraryItemController {
* @param {Response} res
*/
async batchUpdate(req, res) {
if (!req.user.canUpdate) {
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
return res.sendStatus(403)
}
const updatePayloads = req.body
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
@ -615,6 +651,11 @@ class LibraryItemController {
return res.sendStatus(404)
}
// Ensure user has permission to update these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
let itemsUpdated = 0
const seriesIdsRemoved = []
@ -624,6 +665,11 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
continue
}
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
@ -695,6 +741,10 @@ class LibraryItemController {
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds
})
// Ensure user has permission to access these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
res.json({
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
})

View file

@ -63,7 +63,7 @@ class MeController {
* @param {Response} res
*/
async getItemListeningSessions(req, res) {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
@ -71,6 +71,12 @@ class MeController {
return res.sendStatus(404)
}
// Check if user has access to this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
Logger.error(`[MeController] User "${req.user.username}" attempted to access listening sessions for library item "${req.params.libraryItemId}" without access`)
return res.sendStatus(403)
}
const mediaItemId = episode?.id || libraryItem.mediaId
let listeningSessions = await this.getUserItemListeningSessionsHelper(req.user.id, mediaItemId)
@ -125,6 +131,13 @@ class MeController {
* @param {Response} res
*/
async removeMediaProgress(req, res) {
// Verify the media progress belongs to the current user
const mediaProgress = req.user.mediaProgresses.find((mp) => mp.id === req.params.id)
if (!mediaProgress) {
Logger.error(`[MeController] Media progress not found or does not belong to user "${req.user.username}"`)
return res.sendStatus(404)
}
await Database.mediaProgressModel.removeById(req.params.id)
req.user.mediaProgresses = req.user.mediaProgresses.filter((mp) => mp.id !== req.params.id)
@ -192,7 +205,16 @@ class MeController {
* @param {Response} res
*/
async createBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!libraryItem) {
return res.sendStatus(404)
}
// Check if user has access to this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
Logger.error(`[MeController] User "${req.user.username}" attempted to create bookmark for library item "${req.params.id}" without access`)
return res.sendStatus(403)
}
const { time, title } = req.body
if (isNullOrNaN(time)) {
@ -216,7 +238,16 @@ class MeController {
* @param {Response} res
*/
async updateBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!libraryItem) {
return res.sendStatus(404)
}
// Check if user has access to this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
Logger.error(`[MeController] User "${req.user.username}" attempted to update bookmark for library item "${req.params.id}" without access`)
return res.sendStatus(403)
}
const { time, title } = req.body
if (isNullOrNaN(time)) {
@ -245,7 +276,16 @@ class MeController {
* @param {Response} res
*/
async removeBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!libraryItem) {
return res.sendStatus(404)
}
// Check if user has access to this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
Logger.error(`[MeController] User "${req.user.username}" attempted to remove bookmark for library item "${req.params.id}" without access`)
return res.sendStatus(403)
}
const time = Number(req.params.time)
if (isNaN(time)) {

View file

@ -8,7 +8,7 @@ const Database = require('../Database')
const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const cron = require('../libs/nodeCron')
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
@ -605,13 +605,11 @@ class MiscController {
return res.sendStatus(400)
}
try {
patternValidation(expression)
res.sendStatus(200)
} catch (error) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
res.status(400).send(error.message)
if (!cron.validate(expression)) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`)
return res.status(400).send('Invalid cron expression')
}
res.sendStatus(200)
}
/**

View file

@ -2,6 +2,7 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* @typedef RequestUserObject
@ -29,12 +30,17 @@ class PlaylistController {
const reqBody = req.body || {}
// Validation
if (!reqBody.name || !reqBody.libraryId) {
const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
if (!nameCleaned || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist in inaccessible library ${reqBody.libraryId}`)
return res.sendStatus(403)
}
const items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set()
@ -84,7 +90,7 @@ class PlaylistController {
{
libraryId: reqBody.libraryId,
userId: req.user.id,
name: reqBody.name,
name: nameCleaned,
description: reqBody.description || null
},
{ transaction }
@ -131,8 +137,9 @@ class PlaylistController {
*/
async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
const accessiblePlaylists = playlistsForUser.filter((p) => req.user.checkCanAccessLibrary(p.libraryId))
res.json({
playlists: playlistsForUser
playlists: accessiblePlaylists
})
}
@ -174,7 +181,11 @@ class PlaylistController {
}
const playlistUpdatePayload = {}
if (reqBody.name) playlistUpdatePayload.name = reqBody.name
const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name)
if (nameCleaned) {
playlistUpdatePayload.name = nameCleaned
}
if (reqBody.description) playlistUpdatePayload.description = reqBody.description
// Update name and description
@ -502,6 +513,10 @@ class PlaylistController {
if (!collection) {
return res.status(404).send('Collection not found')
}
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist from collection ${collection.id} in inaccessible library ${collection.libraryId}`)
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
if (!collectionExpanded) {
@ -567,6 +582,10 @@ class PlaylistController {
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
return res.sendStatus(403)
}
if (!req.user.checkCanAccessLibrary(playlist.libraryId)) {
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to access playlist ${playlist.id} in inaccessible library ${playlist.libraryId}`)
return res.status(404).send('Playlist not found')
}
req.playlist = playlist
}

View file

@ -5,9 +5,10 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
const cron = require('../libs/nodeCron')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
const { validateUrl } = require('../utils/index')
const htmlSanitizer = require('../utils/htmlSanitizer')
@ -46,6 +47,11 @@ class PodcastController {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
}
if (payload.media.autoDownloadSchedule && !cron.validate(payload.media.autoDownloadSchedule)) {
Logger.error(`[PodcastController] Invalid auto download schedule cron expression "${payload.media.autoDownloadSchedule}"`)
return res.status(400).send('Invalid auto download schedule cron expression')
}
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
@ -58,8 +64,18 @@ class PodcastController {
return res.status(404).send('Folder not found')
}
if (typeof payload.path !== 'string' || !payload.path.trim()) {
return res.status(400).send('Invalid request body. "path" must be a non-empty string')
}
const libraryFolderPath = filePathToPOSIX(folder.path)
const podcastPath = filePathToPOSIX(payload.path)
if (!isSameOrSubPath(libraryFolderPath, podcastPath)) {
Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`)
return res.status(400).send('Podcast path must be inside the selected library folder')
}
// Check if a library item with this podcast folder exists already
const existingLibraryItem =
(await Database.libraryItemModel.count({
@ -83,7 +99,7 @@ class PodcastController {
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
let relPath = payload.path.replace(folder.fullPath, '')
let relPath = podcastPath.replace(libraryFolderPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
let newLibraryItem = null
@ -412,6 +428,12 @@ class PodcastController {
Logger.debug(`[PodcastController] Sanitized description from "${req.body[key]}" to "${sanitizedDescription}"`)
req.body[key] = sanitizedDescription
}
} else if (key === 'subtitle' && req.body[key]) {
const sanitizedSubtitle = htmlSanitizer.sanitize(req.body[key])
if (sanitizedSubtitle !== req.body[key]) {
Logger.debug(`[PodcastController] Sanitized subtitle from "${req.body[key]}" to "${sanitizedSubtitle}"`)
req.body[key] = sanitizedSubtitle
}
}
updatePayload[key] = req.body[key]

View file

@ -53,6 +53,10 @@ class ShareController {
if (playbackSession) {
if (mediaItemShare.id === playbackSession.mediaItemShareId) {
Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)
// If ?t was provided, override the cached currentTime
if (startTime > 0 && startTime < playbackSession.duration) {
playbackSession.currentTime = startTime
}
mediaItemShare.playbackSession = playbackSession.toJSONForClient()
return res.json(mediaItemShare)
} else {

View file

@ -227,7 +227,7 @@ class BookFinder {
title = this.#removeAuthorFromTitle(title)
const titleTransformers = [
[/([,:;_]| by ).*/g, ''], // Remove subtitle
[/(: |[,;_]| by ).*/g, ''], // Remove subtitle
[/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
@ -646,11 +646,11 @@ class BookFinder {
module.exports = new BookFinder()
function hasSubtitle(title) {
return title.includes(':') || title.includes(' - ')
return title.includes(': ') || title.includes(' - ')
}
function stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
if (title.includes(': ')) {
return title.split(': ')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}

View file

@ -5,6 +5,9 @@ const Database = require('../Database')
class ApiCacheManager {
defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: (item) => item.body.length + JSON.stringify(item.headers).length }
defaultTtlOptions = { ttl: 30 * 60 * 1000 }
highChurnModels = new Set(['session', 'mediaProgress', 'playbackSession', 'device'])
modelsInvalidatingPersonalized = new Set(['mediaProgress'])
modelsInvalidatingMe = new Set(['session', 'mediaProgress', 'playbackSession', 'device'])
constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {
this.cache = cache
@ -16,8 +19,47 @@ class ApiCacheManager {
hooks.forEach((hook) => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
}
getModelName(model) {
if (typeof model?.name === 'string') return model.name
if (typeof model?.model?.name === 'string') return model.model.name
if (typeof model?.constructor?.name === 'string' && model.constructor.name !== 'Object') return model.constructor.name
return 'unknown'
}
clearByUrlPattern(urlPattern) {
let removed = 0
for (const key of this.cache.keys()) {
try {
const parsed = JSON.parse(key)
if (typeof parsed?.url === 'string' && urlPattern.test(parsed.url)) {
if (this.cache.delete(key)) removed++
}
} catch {
if (this.cache.delete(key)) removed++
}
}
return removed
}
clearUserProgressSlices(modelName, hook) {
let removedPersonalized = 0
let removedRecentEpisodes = 0
if (this.modelsInvalidatingPersonalized.has(modelName)) {
removedPersonalized = this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/)
removedRecentEpisodes = this.clearByUrlPattern(/^\/libraries\/[^/]+\/recent-episodes/)
}
const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0
Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
}
clear(model, hook) {
Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`)
const modelName = this.getModelName(model)
if (this.highChurnModels.has(modelName)) {
this.clearUserProgressSlices(modelName, hook)
return
}
Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: Clearing cache`)
this.cache.clear()
}

View file

@ -126,13 +126,31 @@ class BackupManager {
} catch (error) {
// Not a valid zip file
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
}
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
if (!entries['absdatabase.sqlite']) {
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
}
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error('[BackupManager] Invalid backup - missing details entry')
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file - missing details entry')
}
if (detailsEntry.size > 1024 * 1024) {
Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(400).send('Invalid backup file - details entry too large')
}
const data = await zip.entryData('details')
const details = data.toString('utf8').split('\n')
@ -140,9 +158,13 @@ class BackupManager {
if (!backup.serverVersion) {
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
await zip.close().catch(() => {})
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
}
await zip.close().catch(() => {})
backup.fileSize = await getFileSize(backup.fullPath)
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
@ -257,9 +279,24 @@ class BackupManager {
let data = null
try {
zip = new StreamZip.async({ file: fullFilePath })
const entries = await zip.entries()
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`)
await zip.close().catch(() => {})
continue
}
if (detailsEntry.size > 1024 * 1024) {
Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`)
await zip.close().catch(() => {})
continue
}
data = await zip.entryData('details')
} catch (error) {
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
if (zip) await zip.close().catch(() => {})
continue
}

View file

@ -153,6 +153,11 @@ class CronManager {
startPodcastCron(expression, libraryItemIds) {
try {
if (!cron.validate(expression)) {
Logger.error(`[CronManager] Invalid auto download schedule cron expression "${expression}" - not starting podcast episode check cron`)
return
}
Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
const task = cron.schedule(expression, () => {
if (this.podcastCronExpressionsExecuting.includes(expression)) {
@ -167,7 +172,7 @@ class CronManager {
task
})
} catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${expression}`, error)
}
}

View file

@ -0,0 +1,74 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface
* @property {import('../Logger')} logger
*
* @typedef MigrationOptions
* @property {MigrationContext} context
*/
const migrationVersion = '2.33.0'
const migrationName = `${migrationVersion}-add-discover-query-indexes`
const loggerPrefix = `[${migrationVersion} migration]`
const indexes = [
{
table: 'mediaProgresses',
name: 'media_progresses_user_item_finished_time',
fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime']
},
{
table: 'bookSeries',
name: 'book_series_series_book',
fields: ['seriesId', 'bookId']
}
]
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
for (const index of indexes) {
await addIndexIfMissing(queryInterface, logger, index)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
for (const index of indexes) {
await removeIndexIfExists(queryInterface, logger, index)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
async function addIndexIfMissing(queryInterface, logger, index) {
const existing = await queryInterface.showIndex(index.table)
if (existing.some((i) => i.name === index.name)) {
logger.info(`${loggerPrefix} index ${index.name} already exists on ${index.table}`)
return
}
logger.info(`${loggerPrefix} adding index ${index.name} on ${index.table}(${index.fields.join(', ')})`)
await queryInterface.addIndex(index.table, {
name: index.name,
fields: index.fields
})
logger.info(`${loggerPrefix} added index ${index.name}`)
}
async function removeIndexIfExists(queryInterface, logger, index) {
const existing = await queryInterface.showIndex(index.table)
if (!existing.some((i) => i.name === index.name)) {
logger.info(`${loggerPrefix} index ${index.name} does not exist on ${index.table}`)
return
}
logger.info(`${loggerPrefix} removing index ${index.name}`)
await queryInterface.removeIndex(index.table, index.name)
logger.info(`${loggerPrefix} removed index ${index.name}`)
}
module.exports = { up, down }

View file

@ -111,16 +111,17 @@ class Author extends Model {
*
* @param {string} name
* @param {string} libraryId
* @returns {Promise<Author>}
* @returns {Promise<{ author: Author, created: boolean }>}
*/
static async findOrCreateByNameAndLibrary(name, libraryId) {
const author = await this.getByNameAndLibrary(name, libraryId)
if (author) return author
return this.create({
if (author) return { author, created: false }
const newAuthor = await this.create({
name,
lastFirst: this.getLastFirst(name),
libraryId
})
return { author: newAuthor, created: true }
}
/**

View file

@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
const htmlSanitizer = require('../utils/htmlSanitizer')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const SocketAuthority = require('../SocketAuthority')
/**
* @typedef EBookFileObject
@ -470,13 +471,23 @@ class Book extends Model {
for (const author of authorsRemoved) {
await bookAuthorModel.removeByIds(author.id, this.id)
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
if (numBooks > 0) {
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
}
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
this.authors = this.authors.filter((au) => au.id !== author.id)
}
const authorsAdded = []
for (const authorName of newAuthorNames) {
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
if (created) {
SocketAuthority.emitter('author_added', author.toOldJSON())
} else {
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
}
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
this.authors.push(author)
authorsAdded.push(author)

View file

@ -48,6 +48,10 @@ class BookSeries extends Model {
{
name: 'bookSeries_seriesId',
fields: ['seriesId']
},
{
name: 'book_series_series_book',
fields: ['seriesId', 'bookId']
}
]
}

View file

@ -340,6 +340,15 @@ class LibraryItem extends Model {
const shelves = []
const timed = async (loader) => {
const start = Date.now()
const payload = await loader()
return {
payload,
elapsedSeconds: ((Date.now() - start) / 1000).toFixed(2)
}
}
// "Continue Listening" shelf
const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
if (itemsInProgressPayload.items.length) {
@ -371,11 +380,18 @@ class LibraryItem extends Model {
}
Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
let start = Date.now()
if (library.isBook) {
start = Date.now()
const [continueSeriesResult, mostRecentResult, seriesMostRecentResult, discoverResult, mediaFinishedResult, newestAuthorsResult] = await Promise.all([
timed(() => libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)),
timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),
timed(() => libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)),
timed(() => libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)),
timed(() => libraryFilters.getMediaFinished(library, user, include, limit)),
timed(() => libraryFilters.getNewestAuthors(library, user, limit))
])
const continueSeriesPayload = continueSeriesResult.payload
// "Continue Series" shelf
const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)
if (continueSeriesPayload.libraryItems.length) {
shelves.push({
id: 'continue-series',
@ -386,42 +402,24 @@ class LibraryItem extends Model {
total: continueSeriesPayload.count
})
}
Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} else if (library.isPodcast) {
// "Newest Episodes" shelf
const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
if (newestEpisodesPayload.libraryItems.length) {
Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${continueSeriesResult.elapsedSeconds}s`)
const mostRecentPayload = mostRecentResult.payload
// "Recently Added" shelf
if (mostRecentPayload.libraryItems.length) {
shelves.push({
id: 'newest-episodes',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: newestEpisodesPayload.libraryItems,
total: newestEpisodesPayload.count
id: 'recently-added',
label: 'Recently Added',
labelStringKey: 'LabelRecentlyAdded',
type: library.mediaType,
entities: mostRecentPayload.libraryItems,
total: mostRecentPayload.count
})
}
Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
}
Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${mostRecentResult.elapsedSeconds}s`)
start = Date.now()
// "Recently Added" shelf
const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)
if (mostRecentPayload.libraryItems.length) {
shelves.push({
id: 'recently-added',
label: 'Recently Added',
labelStringKey: 'LabelRecentlyAdded',
type: library.mediaType,
entities: mostRecentPayload.libraryItems,
total: mostRecentPayload.count
})
}
Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) {
start = Date.now()
const seriesMostRecentPayload = seriesMostRecentResult.payload
// "Recent Series" shelf
const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)
if (seriesMostRecentPayload.series.length) {
shelves.push({
id: 'recent-series',
@ -432,11 +430,10 @@ class LibraryItem extends Model {
total: seriesMostRecentPayload.count
})
}
Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${seriesMostRecentResult.elapsedSeconds}s`)
start = Date.now()
const discoverLibraryItemsPayload = discoverResult.payload
// "Discover" shelf
const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)
if (discoverLibraryItemsPayload.libraryItems.length) {
shelves.push({
id: 'discover',
@ -447,45 +444,41 @@ class LibraryItem extends Model {
total: discoverLibraryItemsPayload.count
})
}
Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
}
Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${discoverResult.elapsedSeconds}s`)
start = Date.now()
// "Listen Again" shelf
const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
if (mediaFinishedPayload.items.length) {
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
const mediaFinishedPayload = mediaFinishedResult.payload
// "Listen Again" shelf
if (mediaFinishedPayload.items.length) {
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
if (audioItemsInProgress.length) {
shelves.push({
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: library.isPodcast ? 'episode' : 'book',
entities: audioItemsInProgress,
total: mediaFinishedPayload.count
})
if (audioItemsInProgress.length) {
shelves.push({
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: library.isPodcast ? 'episode' : 'book',
entities: audioItemsInProgress,
total: mediaFinishedPayload.count
})
}
if (ebookOnlyItemsInProgress.length) {
// "Read Again" shelf
shelves.push({
id: 'read-again',
label: 'Read Again',
labelStringKey: 'LabelReadAgain',
type: 'book',
entities: ebookOnlyItemsInProgress,
total: mediaFinishedPayload.count
})
}
}
Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${mediaFinishedResult.elapsedSeconds}s`)
// "Read Again" shelf
if (ebookOnlyItemsInProgress.length) {
shelves.push({
id: 'read-again',
label: 'Read Again',
labelStringKey: 'LabelReadAgain',
type: 'book',
entities: ebookOnlyItemsInProgress,
total: mediaFinishedPayload.count
})
}
}
Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) {
start = Date.now()
const newestAuthorsPayload = newestAuthorsResult.payload
// "Newest Authors" shelf
const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit)
if (newestAuthorsPayload.authors.length) {
shelves.push({
id: 'newest-authors',
@ -496,7 +489,72 @@ class LibraryItem extends Model {
total: newestAuthorsPayload.count
})
}
Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${newestAuthorsResult.elapsedSeconds}s`)
} else if (library.isPodcast) {
const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([
timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)),
timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),
timed(() => libraryFilters.getMediaFinished(library, user, include, limit))
])
const newestEpisodesPayload = newestEpisodesResult.payload
// "Newest Episodes" shelf
if (newestEpisodesPayload.libraryItems.length) {
shelves.push({
id: 'newest-episodes',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: newestEpisodesPayload.libraryItems,
total: newestEpisodesPayload.count
})
}
Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${newestEpisodesResult.elapsedSeconds}s`)
const mostRecentPayload = mostRecentResult.payload
// "Recently Added" shelf
if (mostRecentPayload.libraryItems.length) {
shelves.push({
id: 'recently-added',
label: 'Recently Added',
labelStringKey: 'LabelRecentlyAdded',
type: library.mediaType,
entities: mostRecentPayload.libraryItems,
total: mostRecentPayload.count
})
}
Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${mostRecentResult.elapsedSeconds}s`)
const mediaFinishedPayload = mediaFinishedResult.payload
// "Listen Again" shelf
if (mediaFinishedPayload.items.length) {
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks)
const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast')
if (audioItemsInProgress.length) {
shelves.push({
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: 'episode',
entities: audioItemsInProgress,
total: mediaFinishedPayload.count
})
}
if (ebookOnlyItemsInProgress.length) {
// "Read Again" shelf
shelves.push({
id: 'read-again',
label: 'Read Again',
labelStringKey: 'LabelReadAgain',
type: 'book',
entities: ebookOnlyItemsInProgress,
total: mediaFinishedPayload.count
})
}
}
Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${mediaFinishedResult.elapsedSeconds}s`)
}
Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)

View file

@ -80,6 +80,10 @@ class MediaProgress extends Model {
indexes: [
{
fields: ['updatedAt']
},
{
name: 'media_progresses_user_item_finished_time',
fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime']
}
]
}

View file

@ -78,6 +78,7 @@ class Podcast extends Model {
*/
static async createFromRequest(payload, transaction) {
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
// cron expression validated in controller
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
@ -89,6 +90,9 @@ class Podcast extends Model {
}
})
const rawDescription = typeof payload.metadata.description === 'string' ? payload.metadata.description : null
const description = rawDescription ? htmlSanitizer.sanitize(rawDescription) : null
return this.create(
{
title,
@ -97,7 +101,7 @@ class Podcast extends Model {
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
description,
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
@ -270,6 +274,7 @@ class Podcast extends Model {
hasUpdates = true
}
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
// cron expression validated in controller
this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true
}

View file

@ -782,7 +782,14 @@ class User extends Model {
error: 'Library item not found',
statusCode: 404
}
} else if (libraryItem.mediaType !== 'book') {
Logger.error(`[User] createUpdateMediaProgress: library item ${progressPayload.libraryItemId} is not a book`)
return {
error: 'Library item is not a book',
statusCode: 400
}
}
mediaItemId = libraryItem.media.id
mediaProgress = libraryItem.media.mediaProgresses?.[0]
}

View file

@ -1,6 +1,10 @@
const uuidv4 = require("uuid").v4
const uuidv4 = require('uuid').v4
const { stripAllTags } = require('../utils/htmlSanitizer')
class DeviceInfo {
/** @type {string[]} Fields to sanitize when loading from stored data */
static stringFields = ['deviceId', 'clientVersion', 'manufacturer', 'model', 'sdkVersion', 'clientName', 'deviceName']
constructor(deviceInfo = null) {
this.id = null
this.userId = null
@ -31,7 +35,7 @@ class DeviceInfo {
construct(deviceInfo) {
for (const key in deviceInfo) {
if (deviceInfo[key] !== undefined && this[key] !== undefined) {
this[key] = deviceInfo[key]
this[key] = DeviceInfo.stringFields.includes(key) ? stripAllTags(deviceInfo[key]) : deviceInfo[key]
}
}
}
@ -63,7 +67,8 @@ class DeviceInfo {
}
get deviceDescription() {
if (this.model) { // Set from mobile apps
if (this.model) {
// Set from mobile apps
if (this.sdkVersion) return `${this.model} SDK ${this.sdkVersion} / v${this.clientVersion}`
return `${this.model} / v${this.clientVersion}`
}
@ -72,18 +77,7 @@ class DeviceInfo {
// When client doesn't send a device id
getTempDeviceId() {
const keys = [
this.userId,
this.browserName,
this.browserVersion,
this.osName,
this.osVersion,
this.clientVersion,
this.manufacturer,
this.model,
this.sdkVersion,
this.ipAddress
].map(k => k || '')
const keys = [this.userId, this.browserName, this.browserVersion, this.osName, this.osVersion, this.clientVersion, this.manufacturer, this.model, this.sdkVersion, this.ipAddress].map((k) => k || '')
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
}
@ -99,12 +93,12 @@ class DeviceInfo {
this.osVersion = ua?.os.version || null
this.deviceType = ua?.device.type || null
this.clientVersion = clientDeviceInfo?.clientVersion || serverVersion
this.manufacturer = clientDeviceInfo?.manufacturer || null
this.model = clientDeviceInfo?.model || null
this.sdkVersion = clientDeviceInfo?.sdkVersion || null
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
this.model = stripAllTags(clientDeviceInfo?.model) || null
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
this.clientName = clientDeviceInfo?.clientName || null
this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
if (this.sdkVersion) {
if (!this.clientName) this.clientName = 'Abs Android'
this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}`
@ -149,4 +143,4 @@ class DeviceInfo {
return hasUpdates
}
}
module.exports = DeviceInfo
module.exports = DeviceInfo

View file

@ -110,7 +110,8 @@ class PlaybackSession {
startedAt: this.startedAt,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
libraryItem: libraryItem?.toOldJSONExpanded() || null
libraryItem: libraryItem?.toOldJSONExpanded() || null,
coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions
}
}

View file

@ -73,7 +73,7 @@ class Stream extends EventEmitter {
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
}
get codecsToForceAAC() {
return ['alac', 'ac3', 'eac3']
return ['alac', 'ac3', 'eac3', 'opus']
}
get userToken() {
return this.user.token

View file

@ -3,6 +3,7 @@ const packageJson = require('../../../package.json')
const { BookshelfView } = require('../../utils/constants')
const Logger = require('../../Logger')
const User = require('../../models/User')
const { sanitize } = require('../../utils/htmlSanitizer')
class ServerSettings {
constructor(settings) {
@ -126,7 +127,7 @@ class ServerSettings {
this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
this.authLoginCustomMessage = settings.authLoginCustomMessage || null // Added v2.8.0
this.authLoginCustomMessage = sanitize(settings.authLoginCustomMessage) || null // Added v2.8.0
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
@ -309,7 +310,7 @@ class ServerSettings {
get authFormData() {
const clientFormData = {
authLoginCustomMessage: this.authLoginCustomMessage
authLoginCustomMessage: sanitize(this.authLoginCustomMessage)
}
if (this.authActiveAuthMethods.includes('openid')) {
clientFormData.authOpenIDButtonText = this.authOpenIDButtonText
@ -327,6 +328,9 @@ class ServerSettings {
update(payload) {
let hasUpdates = false
for (const key in payload) {
if (key === 'authLoginCustomMessage') {
payload[key] = sanitize(payload[key])
}
if (key === 'sortingPrefixes') {
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
continue

View file

@ -363,8 +363,9 @@ class ApiRouter {
* Remove library item and associated entities
* @param {string} libraryItemId
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
* @param {string} libraryId
*/
async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) {
const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: mediaItemIds
@ -395,7 +396,8 @@ class ApiRouter {
await Database.libraryItemModel.removeById(libraryItemId)
SocketAuthority.emitter('item_removed', {
id: libraryItemId
id: libraryItemId,
libraryId
})
}

View file

@ -48,6 +48,8 @@ module.exports.AudioMimeType = {
AIF: 'audio/x-aiff',
WEBM: 'audio/webm',
WEBMA: 'audio/webm',
// TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry
// ref: https://datatracker.ietf.org/doc/html/rfc9559
MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb',
CAF: 'audio/x-caf',

View file

@ -1,4 +1,5 @@
const axios = require('axios')
const ssrfFilter = require('ssrf-req-filter')
const Ffmpeg = require('../libs/fluentFfmpeg')
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
const fs = require('../libs/fsExtra')
@ -97,6 +98,8 @@ async function resizeImage(filePath, outputPath, width, height) {
module.exports.resizeImage = resizeImage
/**
* Download podcast episode
* Uses SSRF filter to prevent internal URLs
*
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns {Promise<{success: boolean, isRequestError?: boolean}>}
@ -121,7 +124,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
Accept: '*/*',
'User-Agent': userAgent
},
timeout: global.PodcastDownloadTimeout
timeout: global.PodcastDownloadTimeout,
httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url),
httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url)
})
Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)

View file

@ -5,11 +5,10 @@ const { entities } = require('./htmlEntities')
*
* @param {string} html
* @returns {string}
* @throws {Error} if input is not a string
*/
function sanitize(html) {
if (typeof html !== 'string') {
throw new Error('sanitizeHtml: input must be a string')
return ''
}
const sanitizerOptions = {
@ -27,6 +26,8 @@ function sanitize(html) {
module.exports.sanitize = sanitize
function stripAllTags(html, shouldDecodeEntities = true) {
if (typeof html !== 'string') return ''
const sanitizerOptions = {
allowedTags: [],
disallowedTagsMode: 'discard'

View file

@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => {
return num === null || isNaN(num)
}
/**
* @param {number|null|undefined} value
* @param {number} max
* @returns {number|null}
*/
module.exports.clampPositiveInt = (value, max) => {
if (value == null || !Number.isFinite(value) || value <= 0) return null
return Math.min(Math.floor(value), max)
}
const xmlToJSON = (xml) => {
return new Promise((resolve, reject) => {
parseString(xml, (err, results) => {

View file

@ -22,7 +22,7 @@ function parseNfoMetadata(nfoText) {
switch (key) {
case 'title':
{
const titleMatch = value.match(/^(.*?):(.*)$/)
const titleMatch = value.match(/^(.*?): (.*)$/)
if (titleMatch) {
metadata.title = titleMatch[1].trim()
metadata.subtitle = titleMatch[2].trim()

View file

@ -217,6 +217,10 @@ function extractEpisodeData(item) {
episode[cleanKey] = extractFirstArrayItemString(item, key)
})
if (episode.subtitle) {
episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim())
}
// Extract psc:chapters if duration is set
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null

View file

@ -888,28 +888,79 @@ module.exports = {
})
}
// Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly)
const { rows: books, count } = await Database.bookModel.findAndCountAll({
where: [
{
'$mediaProgresses.isFinished$': {
[Sequelize.Op.or]: [null, 0]
},
'$mediaProgresses.currentTime$': {
[Sequelize.Op.or]: [null, 0]
},
[Sequelize.Op.or]: [
Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0),
{
id: {
[Sequelize.Op.in]: booksFromSeriesToInclude
}
}
]
const discoverWhere = [
{
'$mediaProgresses.isFinished$': {
[Sequelize.Op.or]: [null, 0]
},
...userPermissionBookWhere.bookWhere
],
'$mediaProgresses.currentTime$': {
[Sequelize.Op.or]: [null, 0]
},
[Sequelize.Op.or]: [
Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0),
{
id: {
[Sequelize.Op.in]: booksFromSeriesToInclude
}
}
]
},
...userPermissionBookWhere.bookWhere
]
const baseDiscoverInclude = [
{
model: Database.libraryItemModel,
where: {
libraryId
}
},
{
model: Database.mediaProgressModel,
where: {
userId: user.id
},
required: false
}
]
// Step 2a: Count with lightweight includes only
const count = await Database.bookModel.count({
where: discoverWhere,
replacements: userPermissionBookWhere.replacements,
include: baseDiscoverInclude,
distinct: true,
col: 'id',
subQuery: false
})
// Step 2b: Select random IDs with lightweight includes only
const randomSelection = await Database.bookModel.findAll({
attributes: ['id'],
where: discoverWhere,
replacements: userPermissionBookWhere.replacements,
include: baseDiscoverInclude,
subQuery: false,
distinct: true,
limit,
order: Database.sequelize.random()
})
const selectedIds = randomSelection.map((b) => b.id).filter(Boolean)
if (!selectedIds.length) {
return {
libraryItems: [],
count
}
}
// Step 2c: Hydrate selected IDs with full metadata for API response
const books = await Database.bookModel.findAll({
where: {
id: {
[Sequelize.Op.in]: selectedIds
}
},
include: [
{
model: Database.libraryItemModel,
@ -918,13 +969,6 @@ module.exports = {
},
include: libraryItemIncludes
},
{
model: Database.mediaProgressModel,
where: {
userId: user.id
},
required: false
},
{
model: Database.bookAuthorModel,
attributes: ['authorId'],
@ -942,14 +986,14 @@ module.exports = {
separate: true
}
],
subQuery: false,
distinct: true,
limit,
order: Database.sequelize.random()
subQuery: false
})
const booksById = new Map(books.map((b) => [b.id, b]))
const orderedBooks = selectedIds.map((id) => booksById.get(id)).filter(Boolean)
// Step 3: Map books to library items
const libraryItems = books.map((bookExpanded) => {
const libraryItems = orderedBooks.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem
const book = bookExpanded
delete book.libraryItem