mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-13 06:51:29 +00:00
Merge branch 'advplyr:master' into auto-generate-chapters-from-timestamps
This commit is contained in:
commit
95fb522e8d
26 changed files with 1202 additions and 87 deletions
|
|
@ -41,6 +41,10 @@ class CollectionController {
|
|||
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')
|
||||
|
|
@ -109,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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -431,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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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')
|
||||
|
|
@ -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() {}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"`)
|
||||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ class PlaylistController {
|
|||
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()
|
||||
|
|
@ -133,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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -508,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) {
|
||||
|
|
@ -573,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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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, isSameOrSubPath } = require('../utils/fileUtils')
|
||||
|
|
@ -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}"`)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -42,11 +42,14 @@ class ApiCacheManager {
|
|||
}
|
||||
|
||||
clearUserProgressSlices(modelName, hook) {
|
||||
const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) : 0
|
||||
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}, me=${removedMe})`
|
||||
)
|
||||
Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
|
||||
}
|
||||
|
||||
clear(model, hook) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue