From dd664da8710c3d13e892b8523ab93237638ce90d Mon Sep 17 00:00:00 2001 From: Cassie Esposito Date: Thu, 19 May 2022 22:10:53 -0700 Subject: [PATCH 01/23] Separated individual element parsing functions out of function getBookDataFromDir --- server/utils/scandir.js | 125 +++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 59 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index faeab015..55072954 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -212,17 +212,38 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { relPath = relPath.replace(/\\/g, '/') var splitDir = relPath.split('/') - // Audio files will always be in the directory named for the title - var [title, narrators] = getTitleAndNarrator(splitDir.pop()) + var title = splitDir.pop() // Audio files will always be in the directory named for the title + series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series + author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ - var series = null - var author = null - // If there are at least 2 more directories, next furthest will be the series - if (splitDir.length > 1) series = splitDir.pop() - if (splitDir.length > 0) author = splitDir.pop() - // There could be many more directories, but only the top 3 are used for naming /author/series/title/ + // The title directory may contain various other pieces of metadata, these functions extract it. + var [title, narrators] = getNarrator(title) + if (series) { var [series, title, sequence] = getSeries(series, title) } + var [title, publishedYear] = getPublishedYear(title) + if (parseSubtitle) { var [title, subtitle] = getSubtitle(title) } + return { + mediaMetadata: { + author, + title, + subtitle, + series, + sequence, + publishedYear, + narrators, + }, + relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. + path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. + } +} +function getNarrator(folder) { + let pattern = /^(?.*)\{(?<narrators>.*)\} *$/ + let match = folder.match(pattern) + return match ? [match.groups.title.trimEnd(), match.groups.narrators] : [folder, null] +} + +function getSeries(series, title) { // If in a series directory check for volume number match /* ACCEPTS Book 2 - Title Here - Subtitle Here @@ -236,33 +257,47 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { 0.5 - Book Title */ var volumeNumber = null - if (series) { - // Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number - var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./) - if (volumeMatch && volumeMatch.length > 1) { - volumeNumber = volumeMatch[1] - title = title.replace(`${volumeNumber} - `, '') - } else { - // Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i) - var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i) - if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) { - volumeNumber = volumeMatch[3] - var replaceChunk = volumeMatch[2] - // "1980 - Book 2-Title Here" - // Group 1 would be "- " - // Group 3 would be "-" - // Only remove the first group - if (volumeMatch[1]) { - replaceChunk = volumeMatch[1] + replaceChunk - } else if (volumeMatch[4]) { - replaceChunk += volumeMatch[4] - } - title = title.replace(replaceChunk, '').trim() + // Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number + var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./) + if (volumeMatch && volumeMatch.length > 1) { + volumeNumber = volumeMatch[1] + title = title.replace(`${volumeNumber} - `, '') + } else { + // Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i) + var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i) + if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) { + volumeNumber = volumeMatch[3] + var replaceChunk = volumeMatch[2] + + // "1980 - Book 2-Title Here" + // Group 1 would be "- " + // Group 3 would be "-" + // Only remove the first group + if (volumeMatch[1]) { + replaceChunk = volumeMatch[1] + replaceChunk + } else if (volumeMatch[4]) { + replaceChunk += volumeMatch[4] } + title = title.replace(replaceChunk, '').trim() } } + return [series, title, volumeNumber] +} +function getSubtitle(title) { + // Subtitle can be parsed from the title if user enabled + // Subtitle is everything after " - " + var subtitle = null + if (title.includes(' - ')) { + var splitOnSubtitle = title.split(' - ') + title = splitOnSubtitle.shift() + subtitle = splitOnSubtitle.join(' - ') + } + return [title, subtitle] +} + +function getPublishedYear(title) { var publishedYear = null // If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/) @@ -276,35 +311,7 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { title = publishYearMatch[2] } } - - // Subtitle can be parsed from the title if user enabled - // Subtitle is everything after " - " - var subtitle = null - if (parseSubtitle && title.includes(' - ')) { - var splitOnSubtitle = title.split(' - ') - title = splitOnSubtitle.shift() - subtitle = splitOnSubtitle.join(' - ') - } - - return { - mediaMetadata: { - author, - title, - subtitle, - series, - sequence: volumeNumber, - publishedYear, - narrators, - }, - relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. - path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. - } -} - -function getTitleAndNarrator(folder) { - let pattern = /^(?<title>.*)\{(?<narrators>.*)\} *$/ - let match = folder.match(pattern) - return match ? [match.groups.title.trimEnd(), match.groups.narrators] : [folder, null] + return [title, publishedYear] } function getPodcastDataFromDir(folderPath, relPath) { From 13d21e90f82131960c1160f13414e07c894090ca Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Thu, 19 May 2022 22:31:55 -0700 Subject: [PATCH 02/23] Cleaned function getSubtitle --- server/utils/scandir.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 55072954..2adbb600 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -220,7 +220,7 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { var [title, narrators] = getNarrator(title) if (series) { var [series, title, sequence] = getSeries(series, title) } var [title, publishedYear] = getPublishedYear(title) - if (parseSubtitle) { var [title, subtitle] = getSubtitle(title) } + if (parseSubtitle) { var [title, subtitle] = getSubtitle(title) } // Subtitle can be parsed from the title if user enabled return { mediaMetadata: { @@ -286,15 +286,9 @@ function getSeries(series, title) { } function getSubtitle(title) { - // Subtitle can be parsed from the title if user enabled // Subtitle is everything after " - " - var subtitle = null - if (title.includes(' - ')) { - var splitOnSubtitle = title.split(' - ') - title = splitOnSubtitle.shift() - subtitle = splitOnSubtitle.join(' - ') - } - return [title, subtitle] + var splitTitle = title.split(' - ') + return [splitTitle.shift(), splitTitle.join(' - ')] } function getPublishedYear(title) { From f1f02b185e9fe48cdea36ec076858dbd74f5d9ec Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Thu, 19 May 2022 22:55:00 -0700 Subject: [PATCH 03/23] Cleaned function getPublishedYear --- server/utils/scandir.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 2adbb600..9bb239aa 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -293,18 +293,14 @@ function getSubtitle(title) { function getPublishedYear(title) { var publishedYear = null - // If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year - var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/) - if (publishYearMatch && publishYearMatch.length > 2 && publishYearMatch[1]) { - // Strip parentheses - if (publishYearMatch[1].startsWith('(') && publishYearMatch[1].endsWith(')')) { - publishYearMatch[1] = publishYearMatch[1].slice(1, -1) - } - if (!isNaN(publishYearMatch[1])) { - publishedYear = publishYearMatch[1] - title = publishYearMatch[2] - } + + pattern = /^\(?([0-9]{4})\)? - (.+)/ //Matches #### - title or (####) - title + var match = title.match(pattern) + if (match) { + publishedYear = match[1] + title = match[2] } + return [title, publishedYear] } From 2d8c840ad6833b33fba15996f0f19ae21927f13f Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Fri, 20 May 2022 01:03:36 -0700 Subject: [PATCH 04/23] Cleaned up function getSequence, became more forgiving of whitespace around metadata elements --- server/utils/scandir.js | 76 ++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 43 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 9bb239aa..bf1e53dc 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -5,6 +5,7 @@ const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils') const globals = require('./globals') const LibraryFile = require('../objects/files/LibraryFile') const { response } = require('express') +const e = require('express') function isMediaFile(mediaType, ext) { // if (!path) return false @@ -218,7 +219,7 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { // The title directory may contain various other pieces of metadata, these functions extract it. var [title, narrators] = getNarrator(title) - if (series) { var [series, title, sequence] = getSeries(series, title) } + if (series) { var [title, sequence] = getSequence(title) } var [title, publishedYear] = getPublishedYear(title) if (parseSubtitle) { var [title, subtitle] = getSubtitle(title) } // Subtitle can be parsed from the title if user enabled @@ -243,58 +244,41 @@ function getNarrator(folder) { return match ? [match.groups.title.trimEnd(), match.groups.narrators] : [folder, null] } -function getSeries(series, title) { - // If in a series directory check for volume number match - /* ACCEPTS - Book 2 - Title Here - Subtitle Here - Title Here - Subtitle Here - Vol 12 - Title Here - volume 9 - Subtitle Here - Vol. 3 Title Here - Subtitle Here - 1980 - Book 2-Title Here - Title Here-Volume 999-Subtitle Here - 2 - Book Title - 100 - Book Title - 0.5 - Book Title - */ - var volumeNumber = null +function getSequence(title) { + // Valid ways of including a volume number: + // Book 2 - Title Here - Subtitle Here + // Title Here - Subtitle Here - Vol 12 + // Title Here - volume 9 - Subtitle Here + // Vol. 3 Title Here - Subtitle Here + // 1980 - Book 2-Title Here + // Title Here-Volume 999-Subtitle Here + // 2 - Book Title + // 100 - Book Title + // 0.5 - Book Title - // Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number - var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./) - if (volumeMatch && volumeMatch.length > 1) { - volumeNumber = volumeMatch[1] - title = title.replace(`${volumeNumber} - `, '') - } else { - // Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i) - var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i) - if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) { - volumeNumber = volumeMatch[3] - var replaceChunk = volumeMatch[2] + // Matches a valid volume string, capturing each section for later processing. + let pattern = /^(vol\.? |volume |book )?(\d{1,3}(?:\.\d{1,2})?)(.*)/i - // "1980 - Book 2-Title Here" - // Group 1 would be "- " - // Group 3 would be "-" - // Only remove the first group - if (volumeMatch[1]) { - replaceChunk = volumeMatch[1] + replaceChunk - } else if (volumeMatch[4]) { - replaceChunk += volumeMatch[4] - } - title = title.replace(replaceChunk, '').trim() + let volumeNumber = null + let parts = title.split('-') + for (let i = 0; i < parts.length; i++) { + let match = parts[i].trim().match(pattern) + if (match && !(match[3].trim() && !match[1])) { // "101 Dalmations" shouldn't match + volumeNumber = match[2] + parts[i] = match[3] + if (!parts[i].trim()) { parts.splice(i, 1) } + break } } - return [series, title, volumeNumber] -} + title = parts.join(' - ') -function getSubtitle(title) { - // Subtitle is everything after " - " - var splitTitle = title.split(' - ') - return [splitTitle.shift(), splitTitle.join(' - ')] + return [title, volumeNumber] } function getPublishedYear(title) { var publishedYear = null - pattern = /^\(?([0-9]{4})\)? - (.+)/ //Matches #### - title or (####) - title + pattern = /^ *\(?([0-9]{4})\)? *- *(.+)/ //Matches #### - title or (####) - title var match = title.match(pattern) if (match) { publishedYear = match[1] @@ -304,6 +288,12 @@ function getPublishedYear(title) { return [title, publishedYear] } +function getSubtitle(title) { + // Subtitle is everything after " - " + var splitTitle = title.split(' - ') + return [splitTitle.shift().trim(), splitTitle.join(' - ').trim()] +} + function getPodcastDataFromDir(folderPath, relPath) { relPath = relPath.replace(/\\/g, '/') var splitDir = relPath.split('/') From 0ad7a98fc7f8e1ce2475c2b3c68ce8c888bbc1f4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 23 May 2022 18:15:15 -0500 Subject: [PATCH 05/23] Add:Support for single book files to be detected by Watcher #610, Fix:Single media file in library folder root is only supported for books not podcasts --- server/Watcher.js | 7 --- server/scanner/Scanner.js | 18 +++++--- server/utils/scandir.js | 91 +++++++++++++++++++++++---------------- 3 files changed, 68 insertions(+), 48 deletions(-) diff --git a/server/Watcher.js b/server/Watcher.js index 555dce06..d2166b6e 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -162,13 +162,6 @@ class FolderWatcher extends EventEmitter { } var folderFullPath = folder.fullPath.replace(/\\/g, '/') - // Check if file was added to root directory - var dir = Path.dirname(path) - if (dir === folderFullPath) { - Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`) - return - } - var relPath = path.replace(folderFullPath, '') var hasDotPath = relPath.split('/').find(p => p.startsWith('.')) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 7e9ffc2e..74e181eb 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -62,7 +62,8 @@ class Scanner { } async scanLibraryItem(libraryMediaType, folder, libraryItem) { - var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings) + // TODO: Support for single media item + var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings) if (!libraryItemData) { return ScanResult.NOTHING } @@ -499,7 +500,11 @@ class Scanner { continue; } var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath) - var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true) + var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) + if (!Object.keys(fileUpdateGroup).length) { + Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`) + continue; + } var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup) Logger.debug(`[Scanner] Folder scan results`, folderScanResults) } @@ -513,6 +518,8 @@ class Scanner { // Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item var updateGroup = { ...fileUpdateGroup } for (const itemDir in updateGroup) { + if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path + var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) if (!itemDirNestedFiles.length) continue; @@ -582,7 +589,8 @@ class Scanner { } Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`) - var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath) + var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir] + var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath, isSingleMediaItem) if (newLibraryItem) { await this.createNewAuthorsAndSeries(newLibraryItem) await this.db.insertLibraryItem(newLibraryItem) @@ -594,8 +602,8 @@ class Scanner { return itemGroupingResults } - async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) { - var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings) + async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) { + var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings) if (!libraryItemData) return null var serverSettings = this.db.serverSettings return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 699a47b9..0c68dacf 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -17,11 +17,14 @@ function isMediaFile(mediaType, ext) { // TODO: Function needs to be re-done // Input: array of relative file paths // Output: map of files grouped into potential item dirs -function groupFilesIntoLibraryItemPaths(paths) { - // Step 1: Clean path, Remove leading "/", Filter out files in root dir +function groupFilesIntoLibraryItemPaths(mediaType, paths) { + // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir var pathsFiltered = paths.map(path => { return path.startsWith('/') ? path.slice(1) : path - }).filter(path => Path.parse(path).dir) + }).filter(path => { + let parsedPath = Path.parse(path) + return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext)) + }) // Step 2: Sort by least number of directories pathsFiltered.sort((a, b) => { @@ -33,25 +36,30 @@ function groupFilesIntoLibraryItemPaths(paths) { // Step 3: Group files in dirs var itemGroup = {} pathsFiltered.forEach((path) => { - var dirparts = Path.dirname(path).split('/') + var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory var numparts = dirparts.length var _path = '' - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) + if (!numparts) { + // Media file in root + itemGroup[path] = path + } else { + // Iterate over directories in path + for (let i = 0; i < numparts; i++) { + var dirpart = dirparts.shift() + _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) - itemGroup[_path].push(relpath) - return - } else if (!dirparts.length) { // This is the last directory, create group - itemGroup[_path] = [Path.basename(path)] - return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group - itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] - return + if (itemGroup[_path]) { // Directory already has files, add file + var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) + itemGroup[_path].push(relpath) + return + } else if (!dirparts.length) { // This is the last directory, create group + itemGroup[_path] = [Path.basename(path)] + return + } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group + itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] + return + } } } }) @@ -62,9 +70,9 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths // Input: array of relative file items (see recurseFiles) // Output: map of files grouped into potential libarary item dirs function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { - // Step 1: Filter out non-media files in root dir (with depth of 0) + // Step 1: Filter out non-book-media files in root dir (with depth of 0) var itemsFiltered = fileItems.filter(i => { - return i.deep > 0 || isMediaFile(mediaType, i.extension) + return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension)) }) // Step 2: Seperate media files and other files @@ -147,16 +155,6 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) { } var fileItems = await recurseFiles(folderPath) - var basePath = folderPath - - const isOpenAudibleFolder = fileItems.find(fi => fi.deep === 0 && fi.name === 'books.json') - if (isOpenAudibleFolder) { - Logger.info(`[scandir] Detected Open Audible Folder, looking in books folder`) - basePath = Path.posix.join(folderPath, 'books') - fileItems = await recurseFiles(basePath) - Logger.debug(`[scandir] ${fileItems.length} files found in books folder`) - } - var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) if (!Object.keys(libraryItemGrouping).length) { @@ -175,10 +173,10 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) { mediaMetadata: { title: Path.basename(libraryItemPath, Path.extname(libraryItemPath)) }, - path: Path.posix.join(basePath, libraryItemPath), + path: Path.posix.join(folderPath, libraryItemPath), relPath: libraryItemPath } - fileObjs = await cleanFileObjects(basePath, [libraryItemPath]) + fileObjs = await cleanFileObjects(folderPath, [libraryItemPath]) isFile = true } else { libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) @@ -335,14 +333,34 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin } // Called from Scanner.js -async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) { - var fileItems = await recurseFiles(libraryItemPath) - +async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) { libraryItemPath = libraryItemPath.replace(/\\/g, '/') var folderFullPath = folder.fullPath.replace(/\\/g, '/') var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) - var libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) + var libraryItemData = {} + + var fileItems = [] + + if (isSingleMediaItem) { // Single media item in root of folder + fileItems = [ + { + fullpath: libraryItemPath, + path: libraryItemDir // actually the relPath (only filename here) + } + ] + libraryItemData = { + path: libraryItemPath, // full path + relPath: libraryItemDir, // only filename + mediaMetadata: { + title: Path.basename(libraryItemDir, Path.extname(libraryItemDir)) + } + } + } else { + fileItems = await recurseFiles(libraryItemPath) + libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) + } + var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) var libraryItem = { ino: libraryItemDirStats.ino, @@ -353,6 +371,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, libraryId: folder.libraryId, path: libraryItemData.path, relPath: libraryItemData.relPath, + isFile: isSingleMediaItem, media: { metadata: libraryItemData.mediaMetadata || null }, From 6cfe583535217a8f4aa31329e57fa01208e282b4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 23 May 2022 18:31:11 -0500 Subject: [PATCH 06/23] Fix:Static router for downloading single file library items #627 --- server/routers/StaticRouter.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/routers/StaticRouter.js b/server/routers/StaticRouter.js index b571869f..24b6f6da 100644 --- a/server/routers/StaticRouter.js +++ b/server/routers/StaticRouter.js @@ -17,7 +17,9 @@ class StaticRouter { if (!item) return res.status(404).send('Item not found with id ' + req.params.id) var remainingPath = req.params['0'] - var fullPath = Path.join(item.path, remainingPath) + var fullPath = null + if (item.isFile) fullPath = item.path + else fullPath = Path.join(item.path, remainingPath) res.sendFile(fullPath) }) } From 3c465994fe094c11d0fb991fec074e3fa7542e85 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 23 May 2022 19:12:40 -0500 Subject: [PATCH 07/23] Fix:Hide remove icon from author images with no image --- client/components/modals/authors/EditModal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/modals/authors/EditModal.vue b/client/components/modals/authors/EditModal.vue index 503f8b12..ef3eb03e 100644 --- a/client/components/modals/authors/EditModal.vue +++ b/client/components/modals/authors/EditModal.vue @@ -11,7 +11,7 @@ <div class="w-40 p-2"> <div class="w-full h-45 relative"> <covers-author-image :author="author" /> - <div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> + <div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> <span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span> </div> </div> From 3e98b6f7499c8cdf96ccaed29a3fe474e37d0566 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 23 May 2022 19:28:00 -0500 Subject: [PATCH 08/23] Update:Remove manual sorting of podcast episodes and default to sort by published at --- .../components/controls/EpisodeSortSelect.vue | 8 +- .../components/modals/authors/EditModal.vue | 2 +- .../tables/podcast/EpisodeTableRow.vue | 29 +++--- .../tables/podcast/EpisodesTable.vue | 93 ++----------------- server/controllers/LibraryItemController.js | 14 --- server/objects/mediaTypes/Podcast.js | 12 +-- server/routers/ApiRouter.js | 1 - 7 files changed, 23 insertions(+), 136 deletions(-) diff --git a/client/components/controls/EpisodeSortSelect.vue b/client/components/controls/EpisodeSortSelect.vue index 27aced3f..7819e4a3 100644 --- a/client/components/controls/EpisodeSortSelect.vue +++ b/client/components/controls/EpisodeSortSelect.vue @@ -33,8 +33,8 @@ export default { showMenu: false, items: [ { - text: 'Current', - value: 'index' + text: 'Pub Date', + value: 'publishedAt' }, { text: 'Title', @@ -47,10 +47,6 @@ export default { { text: 'Episode', value: 'episode' - }, - { - text: 'Pub Date', - value: 'publishedAt' } ] } diff --git a/client/components/modals/authors/EditModal.vue b/client/components/modals/authors/EditModal.vue index ef3eb03e..3657bc85 100644 --- a/client/components/modals/authors/EditModal.vue +++ b/client/components/modals/authors/EditModal.vue @@ -6,7 +6,7 @@ </div> </template> <div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> - <form @submit.prevent="submitForm"> + <form v-if="author" @submit.prevent="submitForm"> <div class="flex"> <div class="w-40 p-2"> <div class="w-full h-45 relative"> diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue index bcb64ecc..b4adeb49 100644 --- a/client/components/tables/podcast/EpisodeTableRow.vue +++ b/client/components/tables/podcast/EpisodeTableRow.vue @@ -1,11 +1,6 @@ <template> <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> <div v-if="episode" class="flex items-center h-24"> - <div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full"> - <div class="flex h-full items-center justify-center"> - <span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span> - </div> - </div> <div class="flex-grow px-2"> <p class="text-sm font-semibold"> {{ title }} @@ -49,8 +44,8 @@ export default { episode: { type: Object, default: () => {} - }, - isDragging: Boolean + } + // isDragging: Boolean }, data() { return { @@ -59,15 +54,15 @@ export default { isHovering: false } }, - watch: { - isDragging: { - handler(newVal) { - if (newVal) { - this.isHovering = false - } - } - } - }, + // watch: { + // isDragging: { + // handler(newVal) { + // if (newVal) { + // this.isHovering = false + // } + // } + // } + // }, computed: { userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] @@ -117,7 +112,7 @@ export default { }, methods: { mouseover() { - if (this.isDragging) return + // if (this.isDragging) return this.isHovering = true }, mouseleave() { diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/EpisodesTable.vue index 3eac0c09..56546f69 100644 --- a/client/components/tables/podcast/EpisodesTable.vue +++ b/client/components/tables/podcast/EpisodesTable.vue @@ -9,23 +9,14 @@ </div> </div> <p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p> - <draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate"> - <transition-group type="transition" :name="!drag ? 'episode' : null"> - <template v-for="episode in episodesCopy"> - <tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" /> - </template> - </transition-group> - </draggable> + <template v-for="episode in episodes"> + <tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @edit="editEpisode" /> + </template> </div> </template> <script> -import draggable from 'vuedraggable' - export default { - components: { - draggable - }, props: { libraryItem: { type: Object, @@ -34,30 +25,11 @@ export default { }, data() { return { - sortKey: 'index', - sortDesc: true, - drag: false, - episodesCopy: [], - orderChanged: false, - savingOrder: false - } - }, - watch: { - libraryItem: { - handler(newVal) { - this.init() - } + sortKey: 'publishedAt', + sortDesc: true } }, computed: { - dragOptions() { - return { - animation: 200, - group: 'description', - ghostClass: 'ghost', - disabled: !this.userCanUpdate - } - }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] }, @@ -72,66 +44,13 @@ export default { } }, methods: { - changeSort() { - this.episodesCopy.sort((a, b) => { - if (this.sortDesc) { - return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) - } - return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) - }) - - this.orderChanged = this.checkHasOrderChanged() - }, - checkHasOrderChanged() { - for (let i = 0; i < this.episodesCopy.length; i++) { - var epc = this.episodesCopy[i] - var ep = this.episodes[i] - if (epc.index != ep.index) { - return true - } - } - return false - }, editEpisode(episode) { this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) - }, - draggableUpdate() { - this.orderChanged = this.checkHasOrderChanged() - }, - async saveOrder() { - if (!this.userCanUpdate) return - - this.savingOrder = true - - var episodesUpdate = { - episodes: this.episodesCopy.map((b) => b.id) - } - await this.$axios - .$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate) - .then((podcast) => { - console.log('Podcast updated', podcast) - this.$toast.success('Saved episode order') - this.orderChanged = false - }) - .catch((error) => { - console.error('Failed to update podcast', error) - this.$toast.error('Failed to save podcast episode order') - }) - this.savingOrder = false - }, - init() { - this.episodesCopy = this.episodes.map((ep) => { - return { - ...ep - } - }) } }, - mounted() { - this.init() - } + mounted() {} } </script> diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 6765e5f1..b920de46 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -224,20 +224,6 @@ class LibraryItemController { res.json(libraryItem.toJSON()) } - // PATCH: api/items/:id/episodes - async updateEpisodes(req, res) { // For updating podcast episode order - var libraryItem = req.libraryItem - var orderedFileData = req.body.episodes - if (!libraryItem.media.setEpisodeOrder) { - Logger.error(`[LibraryItemController] updateEpisodes invalid media type ${libraryItem.id}`) - return res.sendStatus(500) - } - libraryItem.media.setEpisodeOrder(orderedFileData) - await this.db.updateLibraryItem(libraryItem) - this.emitter('item_updated', libraryItem.toJSONExpanded()) - res.json(libraryItem.toJSON()) - } - // DELETE: api/items/:id/episode/:episodeId async removeEpisode(req, res) { var episodeId = req.params.episodeId diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 55ae0c15..a6e38c51 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -224,18 +224,10 @@ class Podcast { this.episodes.push(pe) } - setEpisodeOrder(episodeIds) { - episodeIds.reverse() // episode Ids will already be in descending order - this.episodes = this.episodes.map(ep => { - var indexOf = episodeIds.findIndex(id => id === ep.id) - ep.index = indexOf + 1 - return ep - }) - this.episodes.sort((a, b) => b.index - a.index) - } - reorderEpisodes() { var hasUpdates = false + + // TODO: Sort by published date this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename) for (let i = 0; i < this.episodes.length; i++) { if (this.episodes[i].index !== (i + 1)) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index d2b67114..8af4d9f6 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -90,7 +90,6 @@ class ApiRouter { this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this)) this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this)) this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this)) - this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this)) this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this)) this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) From 1ab933c8b01574013fc21cd937a2f4f34c57cea0 Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Tue, 24 May 2022 16:21:58 -0700 Subject: [PATCH 09/23] Refactored getSequence. Slight behavior changes introduced. All components of the bottom level directory except volume which can no longer use '-' for separation, but 'Vol 4 Title' is still valid and '4. Title' or 'Vol 4.' are also now valid. --- server/utils/scandir.js | 77 ++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index ec25f6bc..e1ec24b9 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -209,15 +209,15 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { relPath = relPath.replace(/\\/g, '/') var splitDir = relPath.split('/') - var title = splitDir.pop() // Audio files will always be in the directory named for the title + var folder = splitDir.pop() // Audio files will always be in the directory named for the title series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ - // The title directory may contain various other pieces of metadata, these functions extract it. - var [title, narrators] = getNarrator(title) - if (series) { var [title, sequence] = getSequence(title) } - var [title, publishedYear] = getPublishedYear(title) - if (parseSubtitle) { var [title, subtitle] = getSubtitle(title) } // Subtitle can be parsed from the title if user enabled + // The may contain various other pieces of metadata, these functions extract it. + var [folder, narrators] = getNarrator(folder) + if (series) { var [folder, sequence] = getSequence(folder) } + var [folder, publishedYear] = getPublishedYear(folder) + if (parseSubtitle) { var [title, subtitle] = getSubtitle(folder) } // Subtitle can be parsed from the title if user enabled return { mediaMetadata: { @@ -235,59 +235,64 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { } function getNarrator(folder) { - let pattern = /^(?<title>.*)\{(?<narrators>.*)\} *$/ + let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/ let match = folder.match(pattern) - return match ? [match.groups.title.trimEnd(), match.groups.narrators] : [folder, null] + return match ? [match.groups.title, match.groups.narrators] : [folder, null] } -function getSequence(title) { +function getSequence(folder) { // Valid ways of including a volume number: - // Book 2 - Title Here - Subtitle Here - // Title Here - Subtitle Here - Vol 12 - // Title Here - volume 9 - Subtitle Here - // Vol. 3 Title Here - Subtitle Here - // 1980 - Book 2-Title Here - // Title Here-Volume 999-Subtitle Here - // 2 - Book Title - // 100 - Book Title - // 0.5 - Book Title + // [ + // 'Book 2 - Title - Subtitle', + // 'Title - Subtitle - Vol 12', + // 'Title - volume 9 - Subtitle', + // 'Vol. 3 Title Here - Subtitle', + // '1980 - Book 2 - Title', + // 'Volume 12. Title - Subtitle', + // '100 - Book Title', + // '2 - Book Title', + // '6. Title', + // '0.5 - Book Title' + // ] - // Matches a valid volume string, capturing each section for later processing. - let pattern = /^(vol\.? |volume |book )?(\d{1,3}(?:\.\d{1,2})?)(.*)/i + // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later. + let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{1,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?/i let volumeNumber = null - let parts = title.split('-') + let parts = folder.split(' - ') for (let i = 0; i < parts.length; i++) { - let match = parts[i].trim().match(pattern) - if (match && !(match[3].trim() && !match[1])) { // "101 Dalmations" shouldn't match - volumeNumber = match[2] - parts[i] = match[3] - if (!parts[i].trim()) { parts.splice(i, 1) } + let match = parts[i].match(pattern) + + // This excludes '101 Dalmations' but includes '101. Dalmations' + if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) { + volumeNumber = match.groups.sequence + parts[i] = match.groups.suffix + if (!parts[i]) { parts.splice(i, 1) } break } } - title = parts.join(' - ') - return [title, volumeNumber] + folder = parts.join(' - ') + return [folder, volumeNumber] } -function getPublishedYear(title) { +function getPublishedYear(folder) { var publishedYear = null - pattern = /^ *\(?([0-9]{4})\)? *- *(.+)/ //Matches #### - title or (####) - title - var match = title.match(pattern) + pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title + var match = folder.match(pattern) if (match) { publishedYear = match[1] - title = match[2] + folder = match[2] } - return [title, publishedYear] + return [folder, publishedYear] } -function getSubtitle(title) { +function getSubtitle(folder) { // Subtitle is everything after " - " - var splitTitle = title.split(' - ') - return [splitTitle.shift().trim(), splitTitle.join(' - ').trim()] + var splitTitle = folder.split(' - ') + return [splitTitle.shift(), splitTitle.join(' - ')] } function getPodcastDataFromDir(folderPath, relPath) { From 5187d0e55f47385c6967de635a7ab301a73f6352 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 24 May 2022 18:38:25 -0500 Subject: [PATCH 10/23] Add:Option to hard delete podcast episode from file system #488 --- .../modals/podcast/RemoveEpisode.vue | 90 +++++++++++++++++++ .../tables/podcast/EpisodeTableRow.vue | 27 +----- .../tables/podcast/EpisodesTable.vue | 40 +++++++-- server/controllers/LibraryItemController.js | 18 ---- server/controllers/PodcastController.js | 29 ++++++ server/routers/ApiRouter.js | 2 +- 6 files changed, 153 insertions(+), 53 deletions(-) create mode 100644 client/components/modals/podcast/RemoveEpisode.vue diff --git a/client/components/modals/podcast/RemoveEpisode.vue b/client/components/modals/podcast/RemoveEpisode.vue new file mode 100644 index 00000000..b7839dae --- /dev/null +++ b/client/components/modals/podcast/RemoveEpisode.vue @@ -0,0 +1,90 @@ +<template> + <modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing"> + <template #outer> + <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> + <p class="font-book text-3xl text-white truncate">{{ title }}</p> + </div> + </template> + <div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden"> + <div class="mb-4"> + <p class="text-lg text-gray-200 mb-4"> + Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span + >? + </p> + <p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p> + </div> + <div class="flex justify-between items-center pt-4"> + <ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" /> + + <ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn> + </div> + </div> + </modals-modal> +</template> + +<script> +export default { + props: { + value: Boolean, + libraryItem: { + type: Object, + default: () => {} + }, + episode: { + type: Object, + default: () => {} + } + }, + data() { + return { + hardDeleteFile: false, + processing: false + } + }, + watch: { + value(newVal) { + if (newVal) this.hardDeleteFile = false + } + }, + computed: { + show: { + get() { + return this.value + }, + set(val) { + this.$emit('input', val) + } + }, + title() { + return 'Remove Episode' + }, + episodeId() { + return this.episode ? this.episode.id : null + }, + episodeTitle() { + return this.episode ? this.episode.title : null + } + }, + methods: { + submit() { + this.processing = true + + var queryString = this.hardDeleteFile ? '?hard=1' : '' + this.$axios + .$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`) + .then(() => { + this.processing = false + this.$toast.success('Podcast episode removed') + this.show = false + }) + .catch((error) => { + var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode' + console.error('Failed update episode', error) + this.processing = false + this.$toast.error(errorMsg) + }) + } + }, + mounted() {} +} +</script> diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue index b4adeb49..0e318858 100644 --- a/client/components/tables/podcast/EpisodeTableRow.vue +++ b/client/components/tables/podcast/EpisodeTableRow.vue @@ -45,7 +45,6 @@ export default { type: Object, default: () => {} } - // isDragging: Boolean }, data() { return { @@ -54,15 +53,6 @@ export default { isHovering: false } }, - // watch: { - // isDragging: { - // handler(newVal) { - // if (newVal) { - // this.isHovering = false - // } - // } - // } - // }, computed: { userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] @@ -149,22 +139,7 @@ export default { }) }, removeClick() { - if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) { - this.processingRemove = true - - this.$axios - .$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`) - .then((updatedPodcast) => { - console.log(`Episode removed from podcast`, updatedPodcast) - this.$toast.success('Episode removed from podcast') - this.processingRemove = false - }) - .catch((error) => { - console.error('Failed to remove episode from podcast', error) - this.$toast.error('Failed to remove episode from podcast') - this.processingRemove = false - }) - } + this.$emit('remove', this.episode) } } } diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/EpisodesTable.vue index 56546f69..6401c893 100644 --- a/client/components/tables/podcast/EpisodesTable.vue +++ b/client/components/tables/podcast/EpisodesTable.vue @@ -3,15 +3,14 @@ <div class="flex items-center mb-4"> <p class="text-lg mb-0 font-semibold">Episodes</p> <div class="flex-grow" /> - <controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" /> - <div v-if="userCanUpdate" class="w-12"> - <ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" /> - </div> + <controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" /> </div> <p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p> - <template v-for="episode in episodes"> - <tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @edit="editEpisode" /> + <template v-for="episode in episodesSorted"> + <tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" /> </template> + + <modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" /> </div> </template> @@ -25,8 +24,16 @@ export default { }, data() { return { + episodesCopy: [], sortKey: 'publishedAt', - sortDesc: true + sortDesc: true, + selectedEpisode: null, + showPodcastRemoveModal: false + } + }, + watch: { + libraryItem() { + this.init() } }, computed: { @@ -41,16 +48,33 @@ export default { }, episodes() { return this.media.episodes || [] + }, + episodesSorted() { + return this.episodesCopy.sort((a, b) => { + if (this.sortDesc) { + return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) + } + return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' }) + }) } }, methods: { + removeEpisode(episode) { + this.selectedEpisode = episode + this.showPodcastRemoveModal = true + }, editEpisode(episode) { this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) + }, + init() { + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) } }, - mounted() {} + mounted() { + this.init() + } } </script> diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index b920de46..070ce416 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -224,24 +224,6 @@ class LibraryItemController { res.json(libraryItem.toJSON()) } - // DELETE: api/items/:id/episode/:episodeId - async removeEpisode(req, res) { - var episodeId = req.params.episodeId - var libraryItem = req.libraryItem - if (libraryItem.mediaType !== 'podcast') { - Logger.error(`[LibraryItemController] removeEpisode invalid media type ${libraryItem.id}`) - return res.sendStatus(500) - } - if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) { - Logger.error(`[LibraryItemController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`) - return res.sendStatus(404) - } - libraryItem.media.removeEpisode(episodeId) - await this.db.updateLibraryItem(libraryItem) - this.emitter('item_updated', libraryItem.toJSONExpanded()) - res.json(libraryItem.toJSON()) - } - // POST api/items/:id/match async match(req, res) { var libraryItem = req.libraryItem diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index e83e108f..a0f14eda 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -190,6 +190,35 @@ class PodcastController { res.json(libraryItem.toJSONExpanded()) } + // DELETE: api/podcasts/:id/episode/:episodeId + async removeEpisode(req, res) { + var episodeId = req.params.episodeId + var libraryItem = req.libraryItem + var hardDelete = req.query.hard === '1' + + var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId) + if (!episode) { + Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`) + return res.sendStatus(404) + } + + if (hardDelete) { + var audioFile = episode.audioFile + // TODO: this will trigger the watcher. should maybe handle this gracefully + await fs.remove(audioFile.metadata.path).then(() => { + Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`) + }).catch((error) => { + Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error) + }) + } + + libraryItem.media.removeEpisode(episodeId) + + await this.db.updateLibraryItem(libraryItem) + this.emitter('item_updated', libraryItem.toJSONExpanded()) + res.json(libraryItem.toJSON()) + } + middleware(req, res, next) { var item = this.db.libraryItems.find(li => li.id === req.params.id) if (!item || !item.media) return res.sendStatus(404) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8af4d9f6..3c3e5333 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -90,7 +90,6 @@ class ApiRouter { this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this)) this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this)) this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this)) - this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this)) this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this)) @@ -188,6 +187,7 @@ class ApiRouter { this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this)) this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this)) this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this)) + this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this)) // // Misc Routes From 6d947bbc294acf7bb9b22eda3960feaf09f7ef18 Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Tue, 24 May 2022 17:06:44 -0700 Subject: [PATCH 11/23] Converted indentation from 4 spaces to 2 --- server/utils/scandir.js | 574 ++++++++++++++++++++-------------------- 1 file changed, 287 insertions(+), 287 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index e1ec24b9..25258d71 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -6,370 +6,370 @@ const globals = require('./globals') const LibraryFile = require('../objects/files/LibraryFile') function isMediaFile(mediaType, ext) { - // if (!path) return false - // var ext = Path.extname(path) - if (!ext) return false - var extclean = ext.slice(1).toLowerCase() - if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) - return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) + // if (!path) return false + // var ext = Path.extname(path) + if (!ext) return false + var extclean = ext.slice(1).toLowerCase() + if (mediaType === 'podcast') return globals.SupportedAudioTypes.includes(extclean) + return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) } // TODO: Function needs to be re-done // Input: array of relative file paths // Output: map of files grouped into potential item dirs function groupFilesIntoLibraryItemPaths(mediaType, paths) { - // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir - var pathsFiltered = paths.map(path => { - return path.startsWith('/') ? path.slice(1) : path - }).filter(path => { - let parsedPath = Path.parse(path) - return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext)) - }) + // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir + var pathsFiltered = paths.map(path => { + return path.startsWith('/') ? path.slice(1) : path + }).filter(path => { + let parsedPath = Path.parse(path) + return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext)) + }) - // Step 2: Sort by least number of directories - pathsFiltered.sort((a, b) => { - var pathsA = Path.dirname(a).split('/').length - var pathsB = Path.dirname(b).split('/').length - return pathsA - pathsB - }) + // Step 2: Sort by least number of directories + pathsFiltered.sort((a, b) => { + var pathsA = Path.dirname(a).split('/').length + var pathsB = Path.dirname(b).split('/').length + return pathsA - pathsB + }) - // Step 3: Group files in dirs - var itemGroup = {} - pathsFiltered.forEach((path) => { - var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory - var numparts = dirparts.length - var _path = '' + // Step 3: Group files in dirs + var itemGroup = {} + pathsFiltered.forEach((path) => { + var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory + var numparts = dirparts.length + var _path = '' - if (!numparts) { - // Media file in root - itemGroup[path] = path - } else { - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) + if (!numparts) { + // Media file in root + itemGroup[path] = path + } else { + // Iterate over directories in path + for (let i = 0; i < numparts; i++) { + var dirpart = dirparts.shift() + _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) - itemGroup[_path].push(relpath) - return - } else if (!dirparts.length) { // This is the last directory, create group - itemGroup[_path] = [Path.basename(path)] - return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group - itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] - return - } - } + if (itemGroup[_path]) { // Directory already has files, add file + var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) + itemGroup[_path].push(relpath) + return + } else if (!dirparts.length) { // This is the last directory, create group + itemGroup[_path] = [Path.basename(path)] + return + } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group + itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] + return } - }) - return itemGroup + } + } + }) + return itemGroup } module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths // Input: array of relative file items (see recurseFiles) // Output: map of files grouped into potential libarary item dirs function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { - // Step 1: Filter out non-book-media files in root dir (with depth of 0) - var itemsFiltered = fileItems.filter(i => { - return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension)) - }) + // Step 1: Filter out non-book-media files in root dir (with depth of 0) + var itemsFiltered = fileItems.filter(i => { + return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension)) + }) - // Step 2: Seperate media files and other files - // - Directories without a media file will not be included - var mediaFileItems = [] - var otherFileItems = [] - itemsFiltered.forEach(item => { - if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item) - else otherFileItems.push(item) - }) + // Step 2: Seperate media files and other files + // - Directories without a media file will not be included + var mediaFileItems = [] + var otherFileItems = [] + itemsFiltered.forEach(item => { + if (isMediaFile(mediaType, item.extension)) mediaFileItems.push(item) + else otherFileItems.push(item) + }) - // Step 3: Group audio files in library items - var libraryItemGroup = {} - mediaFileItems.forEach((item) => { - var dirparts = item.reldirpath.split('/').filter(p => !!p) - var numparts = dirparts.length - var _path = '' + // Step 3: Group audio files in library items + var libraryItemGroup = {} + mediaFileItems.forEach((item) => { + var dirparts = item.reldirpath.split('/').filter(p => !!p) + var numparts = dirparts.length + var _path = '' - if (!dirparts.length) { - // Media file in root - libraryItemGroup[item.name] = item.name - } else { - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) + if (!dirparts.length) { + // Media file in root + libraryItemGroup[item.name] = item.name + } else { + // Iterate over directories in path + for (let i = 0; i < numparts; i++) { + var dirpart = dirparts.shift() + _path = Path.posix.join(_path, dirpart) - if (libraryItemGroup[_path]) { // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), item.name) - libraryItemGroup[_path].push(relpath) - return - } else if (!dirparts.length) { // This is the last directory, create group - libraryItemGroup[_path] = [item.name] - return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group - libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] - return - } - } + if (libraryItemGroup[_path]) { // Directory already has files, add file + var relpath = Path.posix.join(dirparts.join('/'), item.name) + libraryItemGroup[_path].push(relpath) + return + } else if (!dirparts.length) { // This is the last directory, create group + libraryItemGroup[_path] = [item.name] + return + } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group + libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] + return } - }) + } + } + }) - // Step 4: Add other files into library item groups - otherFileItems.forEach((item) => { - var dirparts = item.reldirpath.split('/') - var numparts = dirparts.length - var _path = '' + // Step 4: Add other files into library item groups + otherFileItems.forEach((item) => { + var dirparts = item.reldirpath.split('/') + var numparts = dirparts.length + var _path = '' - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - if (libraryItemGroup[_path]) { // Directory is audiobook group - var relpath = Path.posix.join(dirparts.join('/'), item.name) - libraryItemGroup[_path].push(relpath) - return - } - } - }) - return libraryItemGroup + // Iterate over directories in path + for (let i = 0; i < numparts; i++) { + var dirpart = dirparts.shift() + _path = Path.posix.join(_path, dirpart) + if (libraryItemGroup[_path]) { // Directory is audiobook group + var relpath = Path.posix.join(dirparts.join('/'), item.name) + libraryItemGroup[_path].push(relpath) + return + } + } + }) + return libraryItemGroup } function cleanFileObjects(libraryItemPath, files) { - return Promise.all(files.map(async(file) => { - var filePath = Path.posix.join(libraryItemPath, file) - var newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(filePath, file) - return newLibraryFile - })) + return Promise.all(files.map(async(file) => { + var filePath = Path.posix.join(libraryItemPath, file) + var newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(filePath, file) + return newLibraryFile + })) } // Scan folder async function scanFolder(libraryMediaType, folder, serverSettings = {}) { - var folderPath = folder.fullPath.replace(/\\/g, '/') + var folderPath = folder.fullPath.replace(/\\/g, '/') - var pathExists = await fs.pathExists(folderPath) - if (!pathExists) { - Logger.error(`[scandir] Invalid folder path does not exist "${folderPath}"`) - return [] + var pathExists = await fs.pathExists(folderPath) + if (!pathExists) { + Logger.error(`[scandir] Invalid folder path does not exist "${folderPath}"`) + return [] + } + + var fileItems = await recurseFiles(folderPath) + var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) + + if (!Object.keys(libraryItemGrouping).length) { + Logger.error(`Root path has no media folders: ${folderPath}`) + return [] + } + + var isFile = false // item is not in a folder + var items = [] + for (const libraryItemPath in libraryItemGrouping) { + var libraryItemData = null + var fileObjs = [] + if (libraryItemPath === libraryItemGrouping[libraryItemPath]) { + // Media file in root only get title + libraryItemData = { + mediaMetadata: { + title: Path.basename(libraryItemPath, Path.extname(libraryItemPath)) + }, + path: Path.posix.join(folderPath, libraryItemPath), + relPath: libraryItemPath + } + fileObjs = await cleanFileObjects(folderPath, [libraryItemPath]) + isFile = true + } else { + libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) + fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath]) } - var fileItems = await recurseFiles(folderPath) - var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems) - - if (!Object.keys(libraryItemGrouping).length) { - Logger.error(`Root path has no media folders: ${folderPath}`) - return [] - } - - var isFile = false // item is not in a folder - var items = [] - for (const libraryItemPath in libraryItemGrouping) { - var libraryItemData = null - var fileObjs = [] - if (libraryItemPath === libraryItemGrouping[libraryItemPath]) { - // Media file in root only get title - libraryItemData = { - mediaMetadata: { - title: Path.basename(libraryItemPath, Path.extname(libraryItemPath)) - }, - path: Path.posix.join(folderPath, libraryItemPath), - relPath: libraryItemPath - } - fileObjs = await cleanFileObjects(folderPath, [libraryItemPath]) - isFile = true - } else { - libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) - fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath]) - } - - var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) - items.push({ - folderId: folder.id, - libraryId: folder.libraryId, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - path: libraryItemData.path, - relPath: libraryItemData.relPath, - isFile, - media: { - metadata: libraryItemData.mediaMetadata || null - }, - libraryFiles: fileObjs - }) - } - return items + var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) + items.push({ + folderId: folder.id, + libraryId: folder.libraryId, + ino: libraryItemFolderStats.ino, + mtimeMs: libraryItemFolderStats.mtimeMs || 0, + ctimeMs: libraryItemFolderStats.ctimeMs || 0, + birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, + path: libraryItemData.path, + relPath: libraryItemData.relPath, + isFile, + media: { + metadata: libraryItemData.mediaMetadata || null + }, + libraryFiles: fileObjs + }) + } + return items } module.exports.scanFolder = scanFolder // Input relative filepath, output all details that can be parsed function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { - relPath = relPath.replace(/\\/g, '/') - var splitDir = relPath.split('/') + relPath = relPath.replace(/\\/g, '/') + var splitDir = relPath.split('/') - var folder = splitDir.pop() // Audio files will always be in the directory named for the title - series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series - author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ + var folder = splitDir.pop() // Audio files will always be in the directory named for the title + series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series + author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ - // The may contain various other pieces of metadata, these functions extract it. - var [folder, narrators] = getNarrator(folder) - if (series) { var [folder, sequence] = getSequence(folder) } - var [folder, publishedYear] = getPublishedYear(folder) - if (parseSubtitle) { var [title, subtitle] = getSubtitle(folder) } // Subtitle can be parsed from the title if user enabled + // The may contain various other pieces of metadata, these functions extract it. + var [folder, narrators] = getNarrator(folder) + if (series) { var [folder, sequence] = getSequence(folder) } + var [folder, publishedYear] = getPublishedYear(folder) + if (parseSubtitle) { var [title, subtitle] = getSubtitle(folder) } // Subtitle can be parsed from the title if user enabled - return { - mediaMetadata: { - author, - title, - subtitle, - series, - sequence, - publishedYear, - narrators, - }, - relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. - path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. - } + return { + mediaMetadata: { + author, + title, + subtitle, + series, + sequence, + publishedYear, + narrators, + }, + relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. + path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. + } } function getNarrator(folder) { - let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/ - let match = folder.match(pattern) - return match ? [match.groups.title, match.groups.narrators] : [folder, null] + let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/ + let match = folder.match(pattern) + return match ? [match.groups.title, match.groups.narrators] : [folder, null] } function getSequence(folder) { - // Valid ways of including a volume number: - // [ - // 'Book 2 - Title - Subtitle', - // 'Title - Subtitle - Vol 12', - // 'Title - volume 9 - Subtitle', - // 'Vol. 3 Title Here - Subtitle', - // '1980 - Book 2 - Title', - // 'Volume 12. Title - Subtitle', - // '100 - Book Title', - // '2 - Book Title', - // '6. Title', - // '0.5 - Book Title' - // ] + // Valid ways of including a volume number: + // [ + // 'Book 2 - Title - Subtitle', + // 'Title - Subtitle - Vol 12', + // 'Title - volume 9 - Subtitle', + // 'Vol. 3 Title Here - Subtitle', + // '1980 - Book 2 - Title', + // 'Volume 12. Title - Subtitle', + // '100 - Book Title', + // '2 - Book Title', + // '6. Title', + // '0.5 - Book Title' + // ] - // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later. - let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{1,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?/i + // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later. + let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{1,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?/i - let volumeNumber = null - let parts = folder.split(' - ') - for (let i = 0; i < parts.length; i++) { - let match = parts[i].match(pattern) + let volumeNumber = null + let parts = folder.split(' - ') + for (let i = 0; i < parts.length; i++) { + let match = parts[i].match(pattern) - // This excludes '101 Dalmations' but includes '101. Dalmations' - if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) { - volumeNumber = match.groups.sequence - parts[i] = match.groups.suffix - if (!parts[i]) { parts.splice(i, 1) } - break - } + // This excludes '101 Dalmations' but includes '101. Dalmations' + if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) { + volumeNumber = match.groups.sequence + parts[i] = match.groups.suffix + if (!parts[i]) { parts.splice(i, 1) } + break } + } - folder = parts.join(' - ') - return [folder, volumeNumber] + folder = parts.join(' - ') + return [folder, volumeNumber] } function getPublishedYear(folder) { - var publishedYear = null + var publishedYear = null - pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title - var match = folder.match(pattern) - if (match) { - publishedYear = match[1] - folder = match[2] - } + pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title + var match = folder.match(pattern) + if (match) { + publishedYear = match[1] + folder = match[2] + } - return [folder, publishedYear] + return [folder, publishedYear] } function getSubtitle(folder) { - // Subtitle is everything after " - " - var splitTitle = folder.split(' - ') - return [splitTitle.shift(), splitTitle.join(' - ')] + // Subtitle is everything after " - " + var splitTitle = folder.split(' - ') + return [splitTitle.shift(), splitTitle.join(' - ')] } function getPodcastDataFromDir(folderPath, relPath) { - relPath = relPath.replace(/\\/g, '/') - var splitDir = relPath.split('/') + relPath = relPath.replace(/\\/g, '/') + var splitDir = relPath.split('/') - // Audio files will always be in the directory named for the title - var title = splitDir.pop() - return { - mediaMetadata: { - title - }, - relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. - path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. - } + // Audio files will always be in the directory named for the title + var title = splitDir.pop() + return { + mediaMetadata: { + title + }, + relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/.. + path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/.. + } } function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) { - if (libraryMediaType === 'podcast') { - return getPodcastDataFromDir(folderPath, relPath) - } else { - var parseSubtitle = !!serverSettings.scannerParseSubtitle - return getBookDataFromDir(folderPath, relPath, parseSubtitle) - } + if (libraryMediaType === 'podcast') { + return getPodcastDataFromDir(folderPath, relPath) + } else { + var parseSubtitle = !!serverSettings.scannerParseSubtitle + return getBookDataFromDir(folderPath, relPath, parseSubtitle) + } } // Called from Scanner.js async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) { - libraryItemPath = libraryItemPath.replace(/\\/g, '/') - var folderFullPath = folder.fullPath.replace(/\\/g, '/') + libraryItemPath = libraryItemPath.replace(/\\/g, '/') + var folderFullPath = folder.fullPath.replace(/\\/g, '/') - var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) - var libraryItemData = {} + var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) + var libraryItemData = {} - var fileItems = [] + var fileItems = [] - if (isSingleMediaItem) { // Single media item in root of folder - fileItems = [{ - fullpath: libraryItemPath, - path: libraryItemDir // actually the relPath (only filename here) - }] - libraryItemData = { - path: libraryItemPath, // full path - relPath: libraryItemDir, // only filename - mediaMetadata: { - title: Path.basename(libraryItemDir, Path.extname(libraryItemDir)) - } - } - } else { - fileItems = await recurseFiles(libraryItemPath) - libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) + if (isSingleMediaItem) { // Single media item in root of folder + fileItems = [{ + fullpath: libraryItemPath, + path: libraryItemDir // actually the relPath (only filename here) + }] + libraryItemData = { + path: libraryItemPath, // full path + relPath: libraryItemDir, // only filename + mediaMetadata: { + title: Path.basename(libraryItemDir, Path.extname(libraryItemDir)) + } } + } else { + fileItems = await recurseFiles(libraryItemPath) + libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) + } - var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) - var libraryItem = { - ino: libraryItemDirStats.ino, - mtimeMs: libraryItemDirStats.mtimeMs || 0, - ctimeMs: libraryItemDirStats.ctimeMs || 0, - birthtimeMs: libraryItemDirStats.birthtimeMs || 0, - folderId: folder.id, - libraryId: folder.libraryId, - path: libraryItemData.path, - relPath: libraryItemData.relPath, - isFile: isSingleMediaItem, - media: { - metadata: libraryItemData.mediaMetadata || null - }, - libraryFiles: [] - } + var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) + var libraryItem = { + ino: libraryItemDirStats.ino, + mtimeMs: libraryItemDirStats.mtimeMs || 0, + ctimeMs: libraryItemDirStats.ctimeMs || 0, + birthtimeMs: libraryItemDirStats.birthtimeMs || 0, + folderId: folder.id, + libraryId: folder.libraryId, + path: libraryItemData.path, + relPath: libraryItemData.relPath, + isFile: isSingleMediaItem, + media: { + metadata: libraryItemData.mediaMetadata || null + }, + libraryFiles: [] + } - for (let i = 0; i < fileItems.length; i++) { - var fileItem = fileItems[i] - var newLibraryFile = new LibraryFile() - // fileItem.path is the relative path - await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) - libraryItem.libraryFiles.push(newLibraryFile) - } - return libraryItem + for (let i = 0; i < fileItems.length; i++) { + var fileItem = fileItems[i] + var newLibraryFile = new LibraryFile() + // fileItem.path is the relative path + await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) + libraryItem.libraryFiles.push(newLibraryFile) + } + return libraryItem } module.exports.getLibraryItemFileData = getLibraryItemFileData \ No newline at end of file From d2e012d7b1d8d2f0680d5c7933096415937f13fe Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 24 May 2022 19:19:16 -0500 Subject: [PATCH 12/23] Version bump 2.0.16 --- client/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/package.json b/client/package.json index 35541e86..d212526a 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.0.15", + "version": "2.0.16", "description": "Self-hosted audiobook and podcast client", "main": "index.js", "scripts": { diff --git a/package.json b/package.json index babba376..32946801 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.0.15", + "version": "2.0.16", "description": "Self-hosted audiobook and podcast server", "main": "index.js", "scripts": { From b22173a63174acda8a966bc12b264ac6f87b9267 Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Tue, 24 May 2022 17:30:16 -0700 Subject: [PATCH 13/23] Undoing changes caused by linter run amok --- server/utils/scandir.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 25258d71..f94e015c 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -330,10 +330,12 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, var fileItems = [] if (isSingleMediaItem) { // Single media item in root of folder - fileItems = [{ + fileItems = [ + { fullpath: libraryItemPath, path: libraryItemDir // actually the relPath (only filename here) - }] + } + ] libraryItemData = { path: libraryItemPath, // full path relPath: libraryItemDir, // only filename @@ -366,10 +368,10 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, for (let i = 0; i < fileItems.length; i++) { var fileItem = fileItems[i] var newLibraryFile = new LibraryFile() - // fileItem.path is the relative path + // fileItem.path is the relative path await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) libraryItem.libraryFiles.push(newLibraryFile) } return libraryItem } -module.exports.getLibraryItemFileData = getLibraryItemFileData \ No newline at end of file +module.exports.getLibraryItemFileData = getLibraryItemFileData From 3153bdc5bb71b9d28bc469bdc65978cd05a8d587 Mon Sep 17 00:00:00 2001 From: Cassie Esposito <dev@timevault.org> Date: Tue, 24 May 2022 18:49:45 -0700 Subject: [PATCH 14/23] Fixed bug that caused scanner to fail to get title when subtitle parsing is off, refactored possibly confusing variable declarations. --- server/utils/scandir.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index f94e015c..d3c73269 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -216,8 +216,9 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { // The may contain various other pieces of metadata, these functions extract it. var [folder, narrators] = getNarrator(folder) if (series) { var [folder, sequence] = getSequence(folder) } + var [folder, sequence] = series ? getSequence(folder) : [folder, null] var [folder, publishedYear] = getPublishedYear(folder) - if (parseSubtitle) { var [title, subtitle] = getSubtitle(folder) } // Subtitle can be parsed from the title if user enabled + var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null] return { mediaMetadata: { From 27407d49ddae0a26fae32235cd5b71a36b87ba49 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 24 May 2022 21:44:11 -0500 Subject: [PATCH 15/23] Version bump 2.0.17 --- client/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/package.json b/client/package.json index d212526a..876ef805 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.0.16", + "version": "2.0.17", "description": "Self-hosted audiobook and podcast client", "main": "index.js", "scripts": { diff --git a/package.json b/package.json index 32946801..151d1cd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.0.16", + "version": "2.0.17", "description": "Self-hosted audiobook and podcast server", "main": "index.js", "scripts": { From d8df9a9dff87dc5cf4ab3b794de40399675775db Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 25 May 2022 10:26:21 -0500 Subject: [PATCH 16/23] Update dockerfile for generating client --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 42a7e1b1..bf51a6a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:16-alpine AS build WORKDIR /client COPY /client /client -RUN npm install +RUN npm ci && npm cache clean --force RUN npm run generate ### STAGE 1: Build server ### From 54663f0f0112391e0fb4ed2c92d6b6c74f3f21c6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 26 May 2022 15:10:12 -0500 Subject: [PATCH 17/23] Fix:Listening stats on users page and user listening-sessions api endpoint --- client/pages/config/users/_id.vue | 5 ++++- server/routers/ApiRouter.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/client/pages/config/users/_id.vue b/client/pages/config/users/_id.vue index b44bd900..45758b17 100644 --- a/client/pages/config/users/_id.vue +++ b/client/pages/config/users/_id.vue @@ -22,6 +22,7 @@ <div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="py-2"> <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1> + <p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p> <p class="text-sm text-gray-300"> Total Time Listened:  <span class="font-mono text-base">{{ listeningTimePretty }}</span> @@ -33,7 +34,9 @@ <div v-if="latestSession" class="mt-4"> <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1> - <p class="text-sm text-gray-300">{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</p> + <p class="text-sm text-gray-300"> + <strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span> + </p> </div> </div> <div class="w-full h-px bg-white bg-opacity-10 my-2" /> diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3c3e5333..638dee7d 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -109,7 +109,7 @@ class ApiRouter { this.router.patch('/users/:id', UserController.update.bind(this)) this.router.delete('/users/:id', UserController.delete.bind(this)) - this.router.get('/users/:id/listening-sessions', UserController.getListeningStats.bind(this)) + this.router.get('/users/:id/listening-sessions', UserController.getListeningSessions.bind(this)) this.router.get('/users/:id/listening-stats', UserController.getListeningStats.bind(this)) // From f002532c1e5ac842e8e300a7fcaae689dd0be596 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 26 May 2022 19:09:46 -0500 Subject: [PATCH 18/23] Add:User listening sessions page, Update:Listening sessions to save media times and device info --- client/components/ui/Btn.vue | 8 +- .../config/users/{_id.vue => _id/index.vue} | 9 +- client/pages/config/users/_id/sessions.vue | 146 +++++++++++++++ client/plugins/constants.js | 3 +- client/plugins/init.client.js | 1 + server/controllers/LibraryItemController.js | 7 +- server/libs/isJs.js | 5 + server/libs/requestIp.js | 174 ++++++++++++++++++ server/libs/uaParserJs.js | 4 + server/managers/PlaybackSessionManager.js | 27 ++- server/objects/DeviceInfo.js | 74 ++++++++ server/objects/PlaybackSession.js | 28 ++- 12 files changed, 466 insertions(+), 20 deletions(-) rename client/pages/config/users/{_id.vue => _id/index.vue} (92%) create mode 100644 client/pages/config/users/_id/sessions.vue create mode 100644 server/libs/isJs.js create mode 100644 server/libs/requestIp.js create mode 100644 server/libs/uaParserJs.js create mode 100644 server/objects/DeviceInfo.js diff --git a/client/components/ui/Btn.vue b/client/components/ui/Btn.vue index b1d8a158..5422c278 100644 --- a/client/components/ui/Btn.vue +++ b/client/components/ui/Btn.vue @@ -32,6 +32,7 @@ export default { default: '' }, paddingX: Number, + paddingY: Number, small: Boolean, loading: Boolean, disabled: Boolean @@ -48,14 +49,17 @@ export default { if (this.small) { list.push('text-sm') if (this.paddingX === undefined) list.push('px-4') - list.push('py-1') + if (this.paddingY === undefined) list.push('py-1') } else { if (this.paddingX === undefined) list.push('px-8') - list.push('py-2') + if (this.paddingY === undefined) list.push('py-2') } if (this.paddingX !== undefined) { list.push(`px-${this.paddingX}`) } + if (this.paddingY !== undefined) { + list.push(`py-${this.paddingY}`) + } if (this.disabled) { list.push('cursor-not-allowed') } diff --git a/client/pages/config/users/_id.vue b/client/pages/config/users/_id/index.vue similarity index 92% rename from client/pages/config/users/_id.vue rename to client/pages/config/users/_id/index.vue index 45758b17..8799de85 100644 --- a/client/pages/config/users/_id.vue +++ b/client/pages/config/users/_id/index.vue @@ -22,7 +22,10 @@ <div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="py-2"> <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1> - <p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p> + <div class="flex items-center"> + <p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p> + <ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">View All</ui-btn> + </div> <p class="text-sm text-gray-300"> Total Time Listened:  <span class="font-mono text-base">{{ listeningTimePretty }}</span> @@ -35,7 +38,7 @@ <div v-if="latestSession" class="mt-4"> <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1> <p class="text-sm text-gray-300"> - <strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span> + <strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span> </p> </div> </div> @@ -73,7 +76,7 @@ </td> </tr> </table> - <p v-else class="text-white text-opacity-50">Nothing read yet...</p> + <p v-else class="text-white text-opacity-50">Nothing listened to yet...</p> </div> </div> </div> diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue new file mode 100644 index 00000000..ddd4e803 --- /dev/null +++ b/client/pages/config/users/_id/sessions.vue @@ -0,0 +1,146 @@ +<template> + <div class="w-full h-full"> + <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8"> + <nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0"> + <div class="flex items-center"> + <div class="h-10 w-10 flex items-center justify-center"> + <span class="material-icons text-2xl">arrow_back</span> + </div> + <p class="pl-1">Back to User</p> + </div> + </nuxt-link> + <div class="flex items-center mb-2 mt-4 px-2 sm:px-0"> + <widgets-online-indicator :value="!!userOnline" /> + <h1 class="text-xl pl-2">{{ username }}</h1> + </div> + + <div class="w-full h-px bg-white bg-opacity-10 my-2" /> + + <div class="py-2"> + <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1> + <table v-if="listeningSessions.length" class="userSessionsTable"> + <tr class="bg-primary bg-opacity-40"> + <th class="flex-grow text-left">Item</th> + <th class="w-40 text-left hidden md:table-cell">Play Method</th> + <th class="w-40 text-left hidden sm:table-cell">Device Info</th> + <th class="w-20">Listening Time</th> + <th class="w-20">Last Time</th> + <!-- <th class="w-40 hidden sm:table-cell">Started At</th> --> + <th class="w-40 hidden sm:table-cell">Last Update</th> + </tr> + <tr v-for="session in listeningSessions" :key="session.id"> + <td class="py-1"> + <p class="text-sm text-gray-200">{{ session.displayTitle }}</p> + <p class="text-xs text-gray-400">{{ session.displayAuthor }}</p> + </td> + <td class="hidden md:table-cell"> + <p class="text-xs">{{ getPlayMethodName(session.playMethod) }} with {{ session.mediaPlayer }}</p> + </td> + <td class="hidden sm:table-cell"> + <p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" /> + </td> + <td class="text-center"> + <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> + </td> + <td class="text-center"> + <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> + </td> + <!-- <td class="text-center hidden sm:table-cell"> + <ui-tooltip v-if="session.startedAt" direction="top" :text="$formatDate(session.startedAt, 'MMMM do, yyyy HH:mm')"> + <p class="text-xs">{{ $dateDistanceFromNow(session.startedAt) }}</p> + </ui-tooltip> + </td> --> + <td class="text-center hidden sm:table-cell"> + <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')"> + <p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p> + </ui-tooltip> + </td> + </tr> + </table> + <p v-else class="text-white text-opacity-50">No sessions yet...</p> + </div> + </div> + </div> +</template> + +<script> +export default { + async asyncData({ params, redirect, app }) { + var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => { + console.error('Failed to get user', error) + return null + }) + if (!user) return redirect('/config/users') + return { + user + } + }, + data() { + return { + listeningSessions: [] + } + }, + computed: { + username() { + return this.user.username + }, + userOnline() { + return this.$store.getters['users/getIsUserOnline'](this.user.id) + } + }, + methods: { + getDeviceInfoString(deviceInfo) { + if (!deviceInfo) return '' + var lines = [] + if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`) + if (deviceInfo.browserName) lines.push(deviceInfo.browserName) + + if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`) + if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`) + return lines.join('<br>') + }, + getPlayMethodName(playMethod) { + if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play' + else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode' + else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream' + else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local' + return 'Unknown' + }, + async init() { + console.log(navigator) + + this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => { + console.error('Failed to load listening sesions', err) + return [] + }) + } + }, + mounted() { + this.init() + } +} +</script> + +<style> +.userSessionsTable { + border-collapse: collapse; + width: 100%; + border: 1px solid #474747; +} +.userSessionsTable tr:nth-child(even) { + background-color: #2e2e2e; +} +.userSessionsTable tr:not(:first-child) { + background-color: #373838; +} +.userSessionsTable tr:hover:not(:first-child) { + background-color: #474747; +} +.userSessionsTable td { + padding: 4px 8px; +} +.userSessionsTable th { + padding: 4px 8px; + font-size: 0.75rem; +} +</style> \ No newline at end of file diff --git a/client/plugins/constants.js b/client/plugins/constants.js index 7712c680..79ad488f 100644 --- a/client/plugins/constants.js +++ b/client/plugins/constants.js @@ -28,7 +28,8 @@ const BookshelfView = { const PlayMethod = { DIRECTPLAY: 0, DIRECTSTREAM: 1, - TRANSCODE: 2 + TRANSCODE: 2, + LOCAL: 3 } const Constants = { diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js index 0fab85aa..bf3a6734 100644 --- a/client/plugins/init.client.js +++ b/client/plugins/init.client.js @@ -57,6 +57,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => { } Vue.prototype.$secondsToTimestamp = (seconds) => { + if (!seconds) return '0:00' var _seconds = seconds var _minutes = Math.floor(seconds / 60) _seconds -= _minutes * 60 diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 070ce416..9bd34c75 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -189,8 +189,8 @@ class LibraryItemController { Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) return res.sendStatus(404) } - const options = req.body || {} - this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, null, options, res) + + this.playbackSessionManager.startSessionRequest(req, res, null) } // POST: api/items/:id/play/:episodeId @@ -206,8 +206,7 @@ class LibraryItemController { return res.sendStatus(404) } - const options = req.body || {} - this.playbackSessionManager.startSessionRequest(req.user, libraryItem, episodeId, options, res) + this.playbackSessionManager.startSessionRequest(req, res, episodeId) } // PATCH: api/items/:id/tracks diff --git a/server/libs/isJs.js b/server/libs/isJs.js new file mode 100644 index 00000000..5f4c3439 --- /dev/null +++ b/server/libs/isJs.js @@ -0,0 +1,5 @@ +/*! + * is.js 0.9.0 + * Author: Aras Atasaygin + */ +(function (n, t) { if (typeof define === "function" && define.amd) { define(function () { return n.is = t() }) } else if (typeof exports === "object") { module.exports = t() } else { n.is = t() } })(this, function () { var n = {}; n.VERSION = "0.8.0"; n.not = {}; n.all = {}; n.any = {}; var t = Object.prototype.toString; var e = Array.prototype.slice; var r = Object.prototype.hasOwnProperty; function a(n) { return function () { return !n.apply(null, e.call(arguments)) } } function u(n) { return function () { var t = c(arguments); var e = t.length; for (var r = 0; r < e; r++) { if (!n.call(null, t[r])) { return false } } return true } } function o(n) { return function () { var t = c(arguments); var e = t.length; for (var r = 0; r < e; r++) { if (n.call(null, t[r])) { return true } } return false } } var i = { "<": function (n, t) { return n < t }, "<=": function (n, t) { return n <= t }, ">": function (n, t) { return n > t }, ">=": function (n, t) { return n >= t } }; function f(n, t) { var e = t + ""; var r = +(e.match(/\d+/) || NaN); var a = e.match(/^[<>]=?|/)[0]; return i[a] ? i[a](n, r) : n == r || r !== r } function c(t) { var r = e.call(t); var a = r.length; if (a === 1 && n.array(r[0])) { r = r[0] } return r } n.arguments = function (n) { return t.call(n) === "[object Arguments]" || n != null && typeof n === "object" && "callee" in n }; n.array = Array.isArray || function (n) { return t.call(n) === "[object Array]" }; n.boolean = function (n) { return n === true || n === false || t.call(n) === "[object Boolean]" }; n.char = function (t) { return n.string(t) && t.length === 1 }; n.date = function (n) { return t.call(n) === "[object Date]" }; n.domNode = function (t) { return n.object(t) && t.nodeType > 0 }; n.error = function (n) { return t.call(n) === "[object Error]" }; n["function"] = function (n) { return t.call(n) === "[object Function]" || typeof n === "function" }; n.json = function (n) { return t.call(n) === "[object Object]" }; n.nan = function (n) { return n !== n }; n["null"] = function (n) { return n === null }; n.number = function (e) { return n.not.nan(e) && t.call(e) === "[object Number]" }; n.object = function (n) { return Object(n) === n }; n.regexp = function (n) { return t.call(n) === "[object RegExp]" }; n.sameType = function (e, r) { var a = t.call(e); if (a !== t.call(r)) { return false } if (a === "[object Number]") { return !n.any.nan(e, r) || n.all.nan(e, r) } return true }; n.sameType.api = ["not"]; n.string = function (n) { return t.call(n) === "[object String]" }; n.undefined = function (n) { return n === void 0 }; n.windowObject = function (n) { return n != null && typeof n === "object" && "setInterval" in n }; n.empty = function (t) { if (n.object(t)) { var e = Object.getOwnPropertyNames(t).length; if (e === 0 || e === 1 && n.array(t) || e === 2 && n.arguments(t)) { return true } return false } return t === "" }; n.existy = function (n) { return n != null }; n.falsy = function (n) { return !n }; n.truthy = a(n.falsy); n.above = function (t, e) { return n.all.number(t, e) && t > e }; n.above.api = ["not"]; n.decimal = function (t) { return n.number(t) && t % 1 !== 0 }; n.equal = function (t, e) { if (n.all.number(t, e)) { return t === e && 1 / t === 1 / e } if (n.all.string(t, e) || n.all.regexp(t, e)) { return "" + t === "" + e } if (n.all.boolean(t, e)) { return t === e } return false }; n.equal.api = ["not"]; n.even = function (t) { return n.number(t) && t % 2 === 0 }; n.finite = isFinite || function (t) { return n.not.infinite(t) && n.not.nan(t) }; n.infinite = function (n) { return n === Infinity || n === -Infinity }; n.integer = function (t) { return n.number(t) && t % 1 === 0 }; n.negative = function (t) { return n.number(t) && t < 0 }; n.odd = function (t) { return n.number(t) && t % 2 === 1 }; n.positive = function (t) { return n.number(t) && t > 0 }; n.under = function (t, e) { return n.all.number(t, e) && t < e }; n.under.api = ["not"]; n.within = function (t, e, r) { return n.all.number(t, e, r) && t > e && t < r }; n.within.api = ["not"]; var l = { affirmative: /^(?:1|t(?:rue)?|y(?:es)?|ok(?:ay)?)$/, alphaNumeric: /^[A-Za-z0-9]+$/, caPostalCode: /^(?!.*[DFIOQU])[A-VXY][0-9][A-Z]\s?[0-9][A-Z][0-9]$/, creditCard: /^(?:(4[0-9]{12}(?:[0-9]{3})?)|(5[1-5][0-9]{14})|(6(?:011|5[0-9]{2})[0-9]{12})|(3[47][0-9]{13})|(3(?:0[0-5]|[68][0-9])[0-9]{11})|((?:2131|1800|35[0-9]{3})[0-9]{11}))$/, dateString: /^(1[0-2]|0?[1-9])([\/-])(3[01]|[12][0-9]|0?[1-9])(?:\2)(?:[0-9]{2})?[0-9]{2}$/, email: /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i, eppPhone: /^\+[0-9]{1,3}\.[0-9]{4,14}(?:x.+)?$/, hexadecimal: /^(?:0x)?[0-9a-fA-F]+$/, hexColor: /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, ipv4: /^(?:(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.){3}(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$/, ipv6: /^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i, nanpPhone: /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, socialSecurityNumber: /^(?!000|666)[0-8][0-9]{2}-?(?!00)[0-9]{2}-?(?!0000)[0-9]{4}$/, timeString: /^(2[0-3]|[01]?[0-9]):([0-5]?[0-9]):([0-5]?[0-9])$/, ukPostCode: /^[A-Z]{1,2}[0-9RCHNQ][0-9A-Z]?\s?[0-9][ABD-HJLNP-UW-Z]{2}$|^[A-Z]{2}-?[0-9]{4}$/, url: /^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/i, usZipCode: /^[0-9]{5}(?:-[0-9]{4})?$/ }; function d(t, e) { n[t] = function (n) { return e[t].test(n) } } for (var s in l) { if (l.hasOwnProperty(s)) { d(s, l) } } n.ip = function (t) { return n.ipv4(t) || n.ipv6(t) }; n.capitalized = function (t) { if (n.not.string(t)) { return false } var e = t.split(" "); for (var r = 0; r < e.length; r++) { var a = e[r]; if (a.length) { var u = a.charAt(0); if (u !== u.toUpperCase()) { return false } } } return true }; n.endWith = function (t, e) { if (n.not.string(t)) { return false } e += ""; var r = t.length - e.length; return r >= 0 && t.indexOf(e, r) === r }; n.endWith.api = ["not"]; n.include = function (n, t) { return n.indexOf(t) > -1 }; n.include.api = ["not"]; n.lowerCase = function (t) { return n.string(t) && t === t.toLowerCase() }; n.palindrome = function (t) { if (n.not.string(t)) { return false } t = t.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase(); var e = t.length - 1; for (var r = 0, a = Math.floor(e / 2); r <= a; r++) { if (t.charAt(r) !== t.charAt(e - r)) { return false } } return true }; n.space = function (t) { if (n.not.char(t)) { return false } var e = t.charCodeAt(0); return e > 8 && e < 14 || e === 32 }; n.startWith = function (t, e) { return n.string(t) && t.indexOf(e) === 0 }; n.startWith.api = ["not"]; n.upperCase = function (t) { return n.string(t) && t === t.toUpperCase() }; var F = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; var p = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; n.day = function (t, e) { return n.date(t) && e.toLowerCase() === F[t.getDay()] }; n.day.api = ["not"]; n.dayLightSavingTime = function (n) { var t = new Date(n.getFullYear(), 0, 1); var e = new Date(n.getFullYear(), 6, 1); var r = Math.max(t.getTimezoneOffset(), e.getTimezoneOffset()); return n.getTimezoneOffset() < r }; n.future = function (t) { var e = new Date; return n.date(t) && t.getTime() > e.getTime() }; n.inDateRange = function (t, e, r) { if (n.not.date(t) || n.not.date(e) || n.not.date(r)) { return false } var a = t.getTime(); return a > e.getTime() && a < r.getTime() }; n.inDateRange.api = ["not"]; n.inLastMonth = function (t) { return n.inDateRange(t, new Date((new Date).setMonth((new Date).getMonth() - 1)), new Date) }; n.inLastWeek = function (t) { return n.inDateRange(t, new Date((new Date).setDate((new Date).getDate() - 7)), new Date) }; n.inLastYear = function (t) { return n.inDateRange(t, new Date((new Date).setFullYear((new Date).getFullYear() - 1)), new Date) }; n.inNextMonth = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setMonth((new Date).getMonth() + 1))) }; n.inNextWeek = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setDate((new Date).getDate() + 7))) }; n.inNextYear = function (t) { return n.inDateRange(t, new Date, new Date((new Date).setFullYear((new Date).getFullYear() + 1))) }; n.leapYear = function (t) { return n.number(t) && (t % 4 === 0 && t % 100 !== 0 || t % 400 === 0) }; n.month = function (t, e) { return n.date(t) && e.toLowerCase() === p[t.getMonth()] }; n.month.api = ["not"]; n.past = function (t) { var e = new Date; return n.date(t) && t.getTime() < e.getTime() }; n.quarterOfYear = function (t, e) { return n.date(t) && n.number(e) && e === Math.floor((t.getMonth() + 3) / 3) }; n.quarterOfYear.api = ["not"]; n.today = function (t) { var e = new Date; var r = e.toDateString(); return n.date(t) && t.toDateString() === r }; n.tomorrow = function (t) { var e = new Date; var r = new Date(e.setDate(e.getDate() + 1)).toDateString(); return n.date(t) && t.toDateString() === r }; n.weekend = function (t) { return n.date(t) && (t.getDay() === 6 || t.getDay() === 0) }; n.weekday = a(n.weekend); n.year = function (t, e) { return n.date(t) && n.number(e) && e === t.getFullYear() }; n.year.api = ["not"]; n.yesterday = function (t) { var e = new Date; var r = new Date(e.setDate(e.getDate() - 1)).toDateString(); return n.date(t) && t.toDateString() === r }; var D = n.windowObject(typeof global == "object" && global) && global; var h = n.windowObject(typeof self == "object" && self) && self; var v = n.windowObject(typeof this == "object" && this) && this; var b = D || h || v || Function("return this")(); var g = h && h.document; var m = b.is; var w = h && h.navigator; var y = (w && w.appVersion || "").toLowerCase(); var x = (w && w.userAgent || "").toLowerCase(); var A = (w && w.vendor || "").toLowerCase(); n.android = function () { return /android/.test(x) }; n.android.api = ["not"]; n.androidPhone = function () { return /android/.test(x) && /mobile/.test(x) }; n.androidPhone.api = ["not"]; n.androidTablet = function () { return /android/.test(x) && !/mobile/.test(x) }; n.androidTablet.api = ["not"]; n.blackberry = function () { return /blackberry/.test(x) || /bb10/.test(x) }; n.blackberry.api = ["not"]; n.chrome = function (n) { var t = /google inc/.test(A) ? x.match(/(?:chrome|crios)\/(\d+)/) : null; return t !== null && f(t[1], n) }; n.chrome.api = ["not"]; n.desktop = function () { return n.not.mobile() && n.not.tablet() }; n.desktop.api = ["not"]; n.edge = function (n) { var t = x.match(/edge\/(\d+)/); return t !== null && f(t[1], n) }; n.edge.api = ["not"]; n.firefox = function (n) { var t = x.match(/(?:firefox|fxios)\/(\d+)/); return t !== null && f(t[1], n) }; n.firefox.api = ["not"]; n.ie = function (n) { var t = x.match(/(?:msie |trident.+?; rv:)(\d+)/); return t !== null && f(t[1], n) }; n.ie.api = ["not"]; n.ios = function () { return n.iphone() || n.ipad() || n.ipod() }; n.ios.api = ["not"]; n.ipad = function (n) { var t = x.match(/ipad.+?os (\d+)/); return t !== null && f(t[1], n) }; n.ipad.api = ["not"]; n.iphone = function (n) { var t = x.match(/iphone(?:.+?os (\d+))?/); return t !== null && f(t[1] || 1, n) }; n.iphone.api = ["not"]; n.ipod = function (n) { var t = x.match(/ipod.+?os (\d+)/); return t !== null && f(t[1], n) }; n.ipod.api = ["not"]; n.linux = function () { return /linux/.test(y) }; n.linux.api = ["not"]; n.mac = function () { return /mac/.test(y) }; n.mac.api = ["not"]; n.mobile = function () { return n.iphone() || n.ipod() || n.androidPhone() || n.blackberry() || n.windowsPhone() }; n.mobile.api = ["not"]; n.offline = a(n.online); n.offline.api = ["not"]; n.online = function () { return !w || w.onLine === true }; n.online.api = ["not"]; n.opera = function (n) { var t = x.match(/(?:^opera.+?version|opr)\/(\d+)/); return t !== null && f(t[1], n) }; n.opera.api = ["not"]; n.phantom = function (n) { var t = x.match(/phantomjs\/(\d+)/); return t !== null && f(t[1], n) }; n.phantom.api = ["not"]; n.safari = function (n) { var t = x.match(/version\/(\d+).+?safari/); return t !== null && f(t[1], n) }; n.safari.api = ["not"]; n.tablet = function () { return n.ipad() || n.androidTablet() || n.windowsTablet() }; n.tablet.api = ["not"]; n.touchDevice = function () { return !!g && ("ontouchstart" in h || "DocumentTouch" in h && g instanceof DocumentTouch) }; n.touchDevice.api = ["not"]; n.windows = function () { return /win/.test(y) }; n.windows.api = ["not"]; n.windowsPhone = function () { return n.windows() && /phone/.test(x) }; n.windowsPhone.api = ["not"]; n.windowsTablet = function () { return n.windows() && n.not.windowsPhone() && /touch/.test(x) }; n.windowsTablet.api = ["not"]; n.propertyCount = function (t, e) { if (n.not.object(t) || n.not.number(e)) { return false } var a = 0; for (var u in t) { if (r.call(t, u) && ++a > e) { return false } } return a === e }; n.propertyCount.api = ["not"]; n.propertyDefined = function (t, e) { return n.object(t) && n.string(e) && e in t }; n.propertyDefined.api = ["not"]; n.inArray = function (t, e) { if (n.not.array(e)) { return false } for (var r = 0; r < e.length; r++) { if (e[r] === t) { return true } } return false }; n.inArray.api = ["not"]; n.sorted = function (t, e) { if (n.not.array(t)) { return false } var r = i[e] || i[">="]; for (var a = 1; a < t.length; a++) { if (!r(t[a], t[a - 1])) { return false } } return true }; function j() { var t = n; for (var e in t) { if (r.call(t, e) && n["function"](t[e])) { var i = t[e].api || ["not", "all", "any"]; for (var f = 0; f < i.length; f++) { if (i[f] === "not") { n.not[e] = a(n[e]) } if (i[f] === "all") { n.all[e] = u(n[e]) } if (i[f] === "any") { n.any[e] = o(n[e]) } } } } } j(); n.setNamespace = function () { b.is = m; return this }; n.setRegexp = function (n, t) { for (var e in l) { if (r.call(l, e) && t === e) { l[e] = n } } }; return n }); \ No newline at end of file diff --git a/server/libs/requestIp.js b/server/libs/requestIp.js new file mode 100644 index 00000000..93dbc5e1 --- /dev/null +++ b/server/libs/requestIp.js @@ -0,0 +1,174 @@ +// SOURCE: https://github.com/pbojinov/request-ip + +"use strict"; + +function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +var is = require('./isJs'); +/** + * Parse x-forwarded-for headers. + * + * @param {string} value - The value to be parsed. + * @return {string|null} First known IP address, if any. + */ + + +function getClientIpFromXForwardedFor(value) { + if (!is.existy(value)) { + return null; + } + + if (is.not.string(value)) { + throw new TypeError("Expected a string, got \"".concat(_typeof(value), "\"")); + } // x-forwarded-for may return multiple IP addresses in the format: + // "client IP, proxy 1 IP, proxy 2 IP" + // Therefore, the right-most IP address is the IP address of the most recent proxy + // and the left-most IP address is the IP address of the originating client. + // source: http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html + // Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP) + + + var forwardedIps = value.split(',').map(function (e) { + var ip = e.trim(); + + if (ip.includes(':')) { + var splitted = ip.split(':'); // make sure we only use this if it's ipv4 (ip:port) + + if (splitted.length === 2) { + return splitted[0]; + } + } + + return ip; + }); // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650). + // Therefore taking the left-most IP address that is not unknown + // A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/) + + return forwardedIps.find(is.ip); +} +/** + * Determine client IP address. + * + * @param req + * @returns {string} ip - The IP address if known, defaulting to empty string if unknown. + */ + + +function getClientIp(req) { + // Server is probably behind a proxy. + if (req.headers) { + // Standard headers used by Amazon EC2, Heroku, and others. + if (is.ip(req.headers['x-client-ip'])) { + return req.headers['x-client-ip']; + } // Load-balancers (AWS ELB) or proxies. + + + var xForwardedFor = getClientIpFromXForwardedFor(req.headers['x-forwarded-for']); + + if (is.ip(xForwardedFor)) { + return xForwardedFor; + } // Cloudflare. + // @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers- + // CF-Connecting-IP - applied to every request to the origin. + + + if (is.ip(req.headers['cf-connecting-ip'])) { + return req.headers['cf-connecting-ip']; + } // Fastly and Firebase hosting header (When forwared to cloud function) + + + if (is.ip(req.headers['fastly-client-ip'])) { + return req.headers['fastly-client-ip']; + } // Akamai and Cloudflare: True-Client-IP. + + + if (is.ip(req.headers['true-client-ip'])) { + return req.headers['true-client-ip']; + } // Default nginx proxy/fcgi; alternative to x-forwarded-for, used by some proxies. + + + if (is.ip(req.headers['x-real-ip'])) { + return req.headers['x-real-ip']; + } // (Rackspace LB and Riverbed's Stingray) + // http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address + // https://splash.riverbed.com/docs/DOC-1926 + + + if (is.ip(req.headers['x-cluster-client-ip'])) { + return req.headers['x-cluster-client-ip']; + } + + if (is.ip(req.headers['x-forwarded'])) { + return req.headers['x-forwarded']; + } + + if (is.ip(req.headers['forwarded-for'])) { + return req.headers['forwarded-for']; + } + + if (is.ip(req.headers.forwarded)) { + return req.headers.forwarded; + } + } // Remote address checks. + + + if (is.existy(req.connection)) { + if (is.ip(req.connection.remoteAddress)) { + return req.connection.remoteAddress; + } + + if (is.existy(req.connection.socket) && is.ip(req.connection.socket.remoteAddress)) { + return req.connection.socket.remoteAddress; + } + } + + if (is.existy(req.socket) && is.ip(req.socket.remoteAddress)) { + return req.socket.remoteAddress; + } + + if (is.existy(req.info) && is.ip(req.info.remoteAddress)) { + return req.info.remoteAddress; + } // AWS Api Gateway + Lambda + + + if (is.existy(req.requestContext) && is.existy(req.requestContext.identity) && is.ip(req.requestContext.identity.sourceIp)) { + return req.requestContext.identity.sourceIp; + } + + return null; +} +/** + * Expose request IP as a middleware. + * + * @param {object} [options] - Configuration. + * @param {string} [options.attributeName] - Name of attribute to augment request object with. + * @return {*} + */ + + +function mw(options) { + // Defaults. + var configuration = is.not.existy(options) ? {} : options; // Validation. + + if (is.not.object(configuration)) { + throw new TypeError('Options must be an object!'); + } + + var attributeName = configuration.attributeName || 'clientIp'; + return function (req, res, next) { + var ip = getClientIp(req); + Object.defineProperty(req, attributeName, { + get: function get() { + return ip; + }, + configurable: true + }); + next(); + }; +} + +module.exports = { + getClientIpFromXForwardedFor: getClientIpFromXForwardedFor, + getClientIp: getClientIp, + mw: mw +}; diff --git a/server/libs/uaParserJs.js b/server/libs/uaParserJs.js new file mode 100644 index 00000000..da3a75b4 --- /dev/null +++ b/server/libs/uaParserJs.js @@ -0,0 +1,4 @@ +/* UAParser.js v0.7.31 + Copyright © 2012-2021 Faisal Salman <f@faisalman.com> + MIT License */ +(function (window, undefined) { "use strict"; var LIBVERSION = "0.7.31", EMPTY = "", UNKNOWN = "?", FUNC_TYPE = "function", UNDEF_TYPE = "undefined", OBJ_TYPE = "object", STR_TYPE = "string", MAJOR = "major", MODEL = "model", NAME = "name", TYPE = "type", VENDOR = "vendor", VERSION = "version", ARCHITECTURE = "architecture", CONSOLE = "console", MOBILE = "mobile", TABLET = "tablet", SMARTTV = "smarttv", WEARABLE = "wearable", EMBEDDED = "embedded", UA_MAX_LENGTH = 255; var AMAZON = "Amazon", APPLE = "Apple", ASUS = "ASUS", BLACKBERRY = "BlackBerry", BROWSER = "Browser", CHROME = "Chrome", EDGE = "Edge", FIREFOX = "Firefox", GOOGLE = "Google", HUAWEI = "Huawei", LG = "LG", MICROSOFT = "Microsoft", MOTOROLA = "Motorola", OPERA = "Opera", SAMSUNG = "Samsung", SONY = "Sony", XIAOMI = "Xiaomi", ZEBRA = "Zebra", FACEBOOK = "Facebook"; var extend = function (regexes, extensions) { var mergedRegexes = {}; for (var i in regexes) { if (extensions[i] && extensions[i].length % 2 === 0) { mergedRegexes[i] = extensions[i].concat(regexes[i]) } else { mergedRegexes[i] = regexes[i] } } return mergedRegexes }, enumerize = function (arr) { var enums = {}; for (var i = 0; i < arr.length; i++) { enums[arr[i].toUpperCase()] = arr[i] } return enums }, has = function (str1, str2) { return typeof str1 === STR_TYPE ? lowerize(str2).indexOf(lowerize(str1)) !== -1 : false }, lowerize = function (str) { return str.toLowerCase() }, majorize = function (version) { return typeof version === STR_TYPE ? version.replace(/[^\d\.]/g, EMPTY).split(".")[0] : undefined }, trim = function (str, len) { if (typeof str === STR_TYPE) { str = str.replace(/^\s\s*/, EMPTY).replace(/\s\s*$/, EMPTY); return typeof len === UNDEF_TYPE ? str : str.substring(0, UA_MAX_LENGTH) } }; var rgxMapper = function (ua, arrays) { var i = 0, j, k, p, q, matches, match; while (i < arrays.length && !matches) { var regex = arrays[i], props = arrays[i + 1]; j = k = 0; while (j < regex.length && !matches) { matches = regex[j++].exec(ua); if (!!matches) { for (p = 0; p < props.length; p++) { match = matches[++k]; q = props[p]; if (typeof q === OBJ_TYPE && q.length > 0) { if (q.length === 2) { if (typeof q[1] == FUNC_TYPE) { this[q[0]] = q[1].call(this, match) } else { this[q[0]] = q[1] } } else if (q.length === 3) { if (typeof q[1] === FUNC_TYPE && !(q[1].exec && q[1].test)) { this[q[0]] = match ? q[1].call(this, match, q[2]) : undefined } else { this[q[0]] = match ? match.replace(q[1], q[2]) : undefined } } else if (q.length === 4) { this[q[0]] = match ? q[3].call(this, match.replace(q[1], q[2])) : undefined } } else { this[q] = match ? match : undefined } } } } i += 2 } }, strMapper = function (str, map) { for (var i in map) { if (typeof map[i] === OBJ_TYPE && map[i].length > 0) { for (var j = 0; j < map[i].length; j++) { if (has(map[i][j], str)) { return i === UNKNOWN ? undefined : i } } } else if (has(map[i], str)) { return i === UNKNOWN ? undefined : i } } return str }; var oldSafariMap = { "1.0": "/8", 1.2: "/1", 1.3: "/3", "2.0": "/412", "2.0.2": "/416", "2.0.3": "/417", "2.0.4": "/419", "?": "/" }, windowsVersionMap = { ME: "4.90", "NT 3.11": "NT3.51", "NT 4.0": "NT4.0", 2e3: "NT 5.0", XP: ["NT 5.1", "NT 5.2"], Vista: "NT 6.0", 7: "NT 6.1", 8: "NT 6.2", 8.1: "NT 6.3", 10: ["NT 6.4", "NT 10.0"], RT: "ARM" }; var regexes = { browser: [[/\b(?:crmo|crios)\/([\w\.]+)/i], [VERSION, [NAME, "Chrome"]], [/edg(?:e|ios|a)?\/([\w\.]+)/i], [VERSION, [NAME, "Edge"]], [/(opera mini)\/([-\w\.]+)/i, /(opera [mobiletab]{3,6})\b.+version\/([-\w\.]+)/i, /(opera)(?:.+version\/|[\/ ]+)([\w\.]+)/i], [NAME, VERSION], [/opios[\/ ]+([\w\.]+)/i], [VERSION, [NAME, OPERA + " Mini"]], [/\bopr\/([\w\.]+)/i], [VERSION, [NAME, OPERA]], [/(kindle)\/([\w\.]+)/i, /(lunascape|maxthon|netfront|jasmine|blazer)[\/ ]?([\w\.]*)/i, /(avant |iemobile|slim)(?:browser)?[\/ ]?([\w\.]*)/i, /(ba?idubrowser)[\/ ]?([\w\.]+)/i, /(?:ms|\()(ie) ([\w\.]+)/i, /(flock|rockmelt|midori|epiphany|silk|skyfire|ovibrowser|bolt|iron|vivaldi|iridium|phantomjs|bowser|quark|qupzilla|falkon|rekonq|puffin|brave|whale|qqbrowserlite|qq)\/([-\w\.]+)/i, /(weibo)__([\d\.]+)/i], [NAME, VERSION], [/(?:\buc? ?browser|(?:juc.+)ucweb)[\/ ]?([\w\.]+)/i], [VERSION, [NAME, "UC" + BROWSER]], [/\bqbcore\/([\w\.]+)/i], [VERSION, [NAME, "WeChat(Win) Desktop"]], [/micromessenger\/([\w\.]+)/i], [VERSION, [NAME, "WeChat"]], [/konqueror\/([\w\.]+)/i], [VERSION, [NAME, "Konqueror"]], [/trident.+rv[: ]([\w\.]{1,9})\b.+like gecko/i], [VERSION, [NAME, "IE"]], [/yabrowser\/([\w\.]+)/i], [VERSION, [NAME, "Yandex"]], [/(avast|avg)\/([\w\.]+)/i], [[NAME, /(.+)/, "$1 Secure " + BROWSER], VERSION], [/\bfocus\/([\w\.]+)/i], [VERSION, [NAME, FIREFOX + " Focus"]], [/\bopt\/([\w\.]+)/i], [VERSION, [NAME, OPERA + " Touch"]], [/coc_coc\w+\/([\w\.]+)/i], [VERSION, [NAME, "Coc Coc"]], [/dolfin\/([\w\.]+)/i], [VERSION, [NAME, "Dolphin"]], [/coast\/([\w\.]+)/i], [VERSION, [NAME, OPERA + " Coast"]], [/miuibrowser\/([\w\.]+)/i], [VERSION, [NAME, "MIUI " + BROWSER]], [/fxios\/([-\w\.]+)/i], [VERSION, [NAME, FIREFOX]], [/\bqihu|(qi?ho?o?|360)browser/i], [[NAME, "360 " + BROWSER]], [/(oculus|samsung|sailfish)browser\/([\w\.]+)/i], [[NAME, /(.+)/, "$1 " + BROWSER], VERSION], [/(comodo_dragon)\/([\w\.]+)/i], [[NAME, /_/g, " "], VERSION], [/(electron)\/([\w\.]+) safari/i, /(tesla)(?: qtcarbrowser|\/(20\d\d\.[-\w\.]+))/i, /m?(qqbrowser|baiduboxapp|2345Explorer)[\/ ]?([\w\.]+)/i], [NAME, VERSION], [/(metasr)[\/ ]?([\w\.]+)/i, /(lbbrowser)/i], [NAME], [/((?:fban\/fbios|fb_iab\/fb4a)(?!.+fbav)|;fbav\/([\w\.]+);)/i], [[NAME, FACEBOOK], VERSION], [/safari (line)\/([\w\.]+)/i, /\b(line)\/([\w\.]+)\/iab/i, /(chromium|instagram)[\/ ]([-\w\.]+)/i], [NAME, VERSION], [/\bgsa\/([\w\.]+) .*safari\//i], [VERSION, [NAME, "GSA"]], [/headlesschrome(?:\/([\w\.]+)| )/i], [VERSION, [NAME, CHROME + " Headless"]], [/ wv\).+(chrome)\/([\w\.]+)/i], [[NAME, CHROME + " WebView"], VERSION], [/droid.+ version\/([\w\.]+)\b.+(?:mobile safari|safari)/i], [VERSION, [NAME, "Android " + BROWSER]], [/(chrome|omniweb|arora|[tizenoka]{5} ?browser)\/v?([\w\.]+)/i], [NAME, VERSION], [/version\/([\w\.]+) .*mobile\/\w+ (safari)/i], [VERSION, [NAME, "Mobile Safari"]], [/version\/([\w\.]+) .*(mobile ?safari|safari)/i], [VERSION, NAME], [/webkit.+?(mobile ?safari|safari)(\/[\w\.]+)/i], [NAME, [VERSION, strMapper, oldSafariMap]], [/(webkit|khtml)\/([\w\.]+)/i], [NAME, VERSION], [/(navigator|netscape\d?)\/([-\w\.]+)/i], [[NAME, "Netscape"], VERSION], [/mobile vr; rv:([\w\.]+)\).+firefox/i], [VERSION, [NAME, FIREFOX + " Reality"]], [/ekiohf.+(flow)\/([\w\.]+)/i, /(swiftfox)/i, /(icedragon|iceweasel|camino|chimera|fennec|maemo browser|minimo|conkeror|klar)[\/ ]?([\w\.\+]+)/i, /(seamonkey|k-meleon|icecat|iceape|firebird|phoenix|palemoon|basilisk|waterfox)\/([-\w\.]+)$/i, /(firefox)\/([\w\.]+)/i, /(mozilla)\/([\w\.]+) .+rv\:.+gecko\/\d+/i, /(polaris|lynx|dillo|icab|doris|amaya|w3m|netsurf|sleipnir|obigo|mosaic|(?:go|ice|up)[\. ]?browser)[-\/ ]?v?([\w\.]+)/i, /(links) \(([\w\.]+)/i], [NAME, VERSION]], cpu: [[/(?:(amd|x(?:(?:86|64)[-_])?|wow|win)64)[;\)]/i], [[ARCHITECTURE, "amd64"]], [/(ia32(?=;))/i], [[ARCHITECTURE, lowerize]], [/((?:i[346]|x)86)[;\)]/i], [[ARCHITECTURE, "ia32"]], [/\b(aarch64|arm(v?8e?l?|_?64))\b/i], [[ARCHITECTURE, "arm64"]], [/\b(arm(?:v[67])?ht?n?[fl]p?)\b/i], [[ARCHITECTURE, "armhf"]], [/windows (ce|mobile); ppc;/i], [[ARCHITECTURE, "arm"]], [/((?:ppc|powerpc)(?:64)?)(?: mac|;|\))/i], [[ARCHITECTURE, /ower/, EMPTY, lowerize]], [/(sun4\w)[;\)]/i], [[ARCHITECTURE, "sparc"]], [/((?:avr32|ia64(?=;))|68k(?=\))|\barm(?=v(?:[1-7]|[5-7]1)l?|;|eabi)|(?=atmel )avr|(?:irix|mips|sparc)(?:64)?\b|pa-risc)/i], [[ARCHITECTURE, lowerize]]], device: [[/\b(sch-i[89]0\d|shw-m380s|sm-[pt]\w{2,4}|gt-[pn]\d{2,4}|sgh-t8[56]9|nexus 10)/i], [MODEL, [VENDOR, SAMSUNG], [TYPE, TABLET]], [/\b((?:s[cgp]h|gt|sm)-\w+|galaxy nexus)/i, /samsung[- ]([-\w]+)/i, /sec-(sgh\w+)/i], [MODEL, [VENDOR, SAMSUNG], [TYPE, MOBILE]], [/\((ip(?:hone|od)[\w ]*);/i], [MODEL, [VENDOR, APPLE], [TYPE, MOBILE]], [/\((ipad);[-\w\),; ]+apple/i, /applecoremedia\/[\w\.]+ \((ipad)/i, /\b(ipad)\d\d?,\d\d?[;\]].+ios/i], [MODEL, [VENDOR, APPLE], [TYPE, TABLET]], [/\b((?:ag[rs][23]?|bah2?|sht?|btv)-a?[lw]\d{2})\b(?!.+d\/s)/i], [MODEL, [VENDOR, HUAWEI], [TYPE, TABLET]], [/(?:huawei|honor)([-\w ]+)[;\)]/i, /\b(nexus 6p|\w{2,4}-[atu]?[ln][01259x][012359][an]?)\b(?!.+d\/s)/i], [MODEL, [VENDOR, HUAWEI], [TYPE, MOBILE]], [/\b(poco[\w ]+)(?: bui|\))/i, /\b; (\w+) build\/hm\1/i, /\b(hm[-_ ]?note?[_ ]?(?:\d\w)?) bui/i, /\b(redmi[\-_ ]?(?:note|k)?[\w_ ]+)(?: bui|\))/i, /\b(mi[-_ ]?(?:a\d|one|one[_ ]plus|note lte|max)?[_ ]?(?:\d?\w?)[_ ]?(?:plus|se|lite)?)(?: bui|\))/i], [[MODEL, /_/g, " "], [VENDOR, XIAOMI], [TYPE, MOBILE]], [/\b(mi[-_ ]?(?:pad)(?:[\w_ ]+))(?: bui|\))/i], [[MODEL, /_/g, " "], [VENDOR, XIAOMI], [TYPE, TABLET]], [/; (\w+) bui.+ oppo/i, /\b(cph[12]\d{3}|p(?:af|c[al]|d\w|e[ar])[mt]\d0|x9007|a101op)\b/i], [MODEL, [VENDOR, "OPPO"], [TYPE, MOBILE]], [/vivo (\w+)(?: bui|\))/i, /\b(v[12]\d{3}\w?[at])(?: bui|;)/i], [MODEL, [VENDOR, "Vivo"], [TYPE, MOBILE]], [/\b(rmx[12]\d{3})(?: bui|;|\))/i], [MODEL, [VENDOR, "Realme"], [TYPE, MOBILE]], [/\b(milestone|droid(?:[2-4x]| (?:bionic|x2|pro|razr))?:?( 4g)?)\b[\w ]+build\//i, /\bmot(?:orola)?[- ](\w*)/i, /((?:moto[\w\(\) ]+|xt\d{3,4}|nexus 6)(?= bui|\)))/i], [MODEL, [VENDOR, MOTOROLA], [TYPE, MOBILE]], [/\b(mz60\d|xoom[2 ]{0,2}) build\//i], [MODEL, [VENDOR, MOTOROLA], [TYPE, TABLET]], [/((?=lg)?[vl]k\-?\d{3}) bui| 3\.[-\w; ]{10}lg?-([06cv9]{3,4})/i], [MODEL, [VENDOR, LG], [TYPE, TABLET]], [/(lm(?:-?f100[nv]?|-[\w\.]+)(?= bui|\))|nexus [45])/i, /\blg[-e;\/ ]+((?!browser|netcast|android tv)\w+)/i, /\blg-?([\d\w]+) bui/i], [MODEL, [VENDOR, LG], [TYPE, MOBILE]], [/(ideatab[-\w ]+)/i, /lenovo ?(s[56]000[-\w]+|tab(?:[\w ]+)|yt[-\d\w]{6}|tb[-\d\w]{6})/i], [MODEL, [VENDOR, "Lenovo"], [TYPE, TABLET]], [/(?:maemo|nokia).*(n900|lumia \d+)/i, /nokia[-_ ]?([-\w\.]*)/i], [[MODEL, /_/g, " "], [VENDOR, "Nokia"], [TYPE, MOBILE]], [/(pixel c)\b/i], [MODEL, [VENDOR, GOOGLE], [TYPE, TABLET]], [/droid.+; (pixel[\daxl ]{0,6})(?: bui|\))/i], [MODEL, [VENDOR, GOOGLE], [TYPE, MOBILE]], [/droid.+ ([c-g]\d{4}|so[-gl]\w+|xq-a\w[4-7][12])(?= bui|\).+chrome\/(?![1-6]{0,1}\d\.))/i], [MODEL, [VENDOR, SONY], [TYPE, MOBILE]], [/sony tablet [ps]/i, /\b(?:sony)?sgp\w+(?: bui|\))/i], [[MODEL, "Xperia Tablet"], [VENDOR, SONY], [TYPE, TABLET]], [/ (kb2005|in20[12]5|be20[12][59])\b/i, /(?:one)?(?:plus)? (a\d0\d\d)(?: b|\))/i], [MODEL, [VENDOR, "OnePlus"], [TYPE, MOBILE]], [/(alexa)webm/i, /(kf[a-z]{2}wi)( bui|\))/i, /(kf[a-z]+)( bui|\)).+silk\//i], [MODEL, [VENDOR, AMAZON], [TYPE, TABLET]], [/((?:sd|kf)[0349hijorstuw]+)( bui|\)).+silk\//i], [[MODEL, /(.+)/g, "Fire Phone $1"], [VENDOR, AMAZON], [TYPE, MOBILE]], [/(playbook);[-\w\),; ]+(rim)/i], [MODEL, VENDOR, [TYPE, TABLET]], [/\b((?:bb[a-f]|st[hv])100-\d)/i, /\(bb10; (\w+)/i], [MODEL, [VENDOR, BLACKBERRY], [TYPE, MOBILE]], [/(?:\b|asus_)(transfo[prime ]{4,10} \w+|eeepc|slider \w+|nexus 7|padfone|p00[cj])/i], [MODEL, [VENDOR, ASUS], [TYPE, TABLET]], [/ (z[bes]6[027][012][km][ls]|zenfone \d\w?)\b/i], [MODEL, [VENDOR, ASUS], [TYPE, MOBILE]], [/(nexus 9)/i], [MODEL, [VENDOR, "HTC"], [TYPE, TABLET]], [/(htc)[-;_ ]{1,2}([\w ]+(?=\)| bui)|\w+)/i, /(zte)[- ]([\w ]+?)(?: bui|\/|\))/i, /(alcatel|geeksphone|nexian|panasonic|sony)[-_ ]?([-\w]*)/i], [VENDOR, [MODEL, /_/g, " "], [TYPE, MOBILE]], [/droid.+; ([ab][1-7]-?[0178a]\d\d?)/i], [MODEL, [VENDOR, "Acer"], [TYPE, TABLET]], [/droid.+; (m[1-5] note) bui/i, /\bmz-([-\w]{2,})/i], [MODEL, [VENDOR, "Meizu"], [TYPE, MOBILE]], [/\b(sh-?[altvz]?\d\d[a-ekm]?)/i], [MODEL, [VENDOR, "Sharp"], [TYPE, MOBILE]], [/(blackberry|benq|palm(?=\-)|sonyericsson|acer|asus|dell|meizu|motorola|polytron)[-_ ]?([-\w]*)/i, /(hp) ([\w ]+\w)/i, /(asus)-?(\w+)/i, /(microsoft); (lumia[\w ]+)/i, /(lenovo)[-_ ]?([-\w]+)/i, /(jolla)/i, /(oppo) ?([\w ]+) bui/i], [VENDOR, MODEL, [TYPE, MOBILE]], [/(archos) (gamepad2?)/i, /(hp).+(touchpad(?!.+tablet)|tablet)/i, /(kindle)\/([\w\.]+)/i, /(nook)[\w ]+build\/(\w+)/i, /(dell) (strea[kpr\d ]*[\dko])/i, /(le[- ]+pan)[- ]+(\w{1,9}) bui/i, /(trinity)[- ]*(t\d{3}) bui/i, /(gigaset)[- ]+(q\w{1,9}) bui/i, /(vodafone) ([\w ]+)(?:\)| bui)/i], [VENDOR, MODEL, [TYPE, TABLET]], [/(surface duo)/i], [MODEL, [VENDOR, MICROSOFT], [TYPE, TABLET]], [/droid [\d\.]+; (fp\du?)(?: b|\))/i], [MODEL, [VENDOR, "Fairphone"], [TYPE, MOBILE]], [/(u304aa)/i], [MODEL, [VENDOR, "AT&T"], [TYPE, MOBILE]], [/\bsie-(\w*)/i], [MODEL, [VENDOR, "Siemens"], [TYPE, MOBILE]], [/\b(rct\w+) b/i], [MODEL, [VENDOR, "RCA"], [TYPE, TABLET]], [/\b(venue[\d ]{2,7}) b/i], [MODEL, [VENDOR, "Dell"], [TYPE, TABLET]], [/\b(q(?:mv|ta)\w+) b/i], [MODEL, [VENDOR, "Verizon"], [TYPE, TABLET]], [/\b(?:barnes[& ]+noble |bn[rt])([\w\+ ]*) b/i], [MODEL, [VENDOR, "Barnes & Noble"], [TYPE, TABLET]], [/\b(tm\d{3}\w+) b/i], [MODEL, [VENDOR, "NuVision"], [TYPE, TABLET]], [/\b(k88) b/i], [MODEL, [VENDOR, "ZTE"], [TYPE, TABLET]], [/\b(nx\d{3}j) b/i], [MODEL, [VENDOR, "ZTE"], [TYPE, MOBILE]], [/\b(gen\d{3}) b.+49h/i], [MODEL, [VENDOR, "Swiss"], [TYPE, MOBILE]], [/\b(zur\d{3}) b/i], [MODEL, [VENDOR, "Swiss"], [TYPE, TABLET]], [/\b((zeki)?tb.*\b) b/i], [MODEL, [VENDOR, "Zeki"], [TYPE, TABLET]], [/\b([yr]\d{2}) b/i, /\b(dragon[- ]+touch |dt)(\w{5}) b/i], [[VENDOR, "Dragon Touch"], MODEL, [TYPE, TABLET]], [/\b(ns-?\w{0,9}) b/i], [MODEL, [VENDOR, "Insignia"], [TYPE, TABLET]], [/\b((nxa|next)-?\w{0,9}) b/i], [MODEL, [VENDOR, "NextBook"], [TYPE, TABLET]], [/\b(xtreme\_)?(v(1[045]|2[015]|[3469]0|7[05])) b/i], [[VENDOR, "Voice"], MODEL, [TYPE, MOBILE]], [/\b(lvtel\-)?(v1[12]) b/i], [[VENDOR, "LvTel"], MODEL, [TYPE, MOBILE]], [/\b(ph-1) /i], [MODEL, [VENDOR, "Essential"], [TYPE, MOBILE]], [/\b(v(100md|700na|7011|917g).*\b) b/i], [MODEL, [VENDOR, "Envizen"], [TYPE, TABLET]], [/\b(trio[-\w\. ]+) b/i], [MODEL, [VENDOR, "MachSpeed"], [TYPE, TABLET]], [/\btu_(1491) b/i], [MODEL, [VENDOR, "Rotor"], [TYPE, TABLET]], [/(shield[\w ]+) b/i], [MODEL, [VENDOR, "Nvidia"], [TYPE, TABLET]], [/(sprint) (\w+)/i], [VENDOR, MODEL, [TYPE, MOBILE]], [/(kin\.[onetw]{3})/i], [[MODEL, /\./g, " "], [VENDOR, MICROSOFT], [TYPE, MOBILE]], [/droid.+; (cc6666?|et5[16]|mc[239][23]x?|vc8[03]x?)\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, TABLET]], [/droid.+; (ec30|ps20|tc[2-8]\d[kx])\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, MOBILE]], [/(ouya)/i, /(nintendo) ([wids3utch]+)/i], [VENDOR, MODEL, [TYPE, CONSOLE]], [/droid.+; (shield) bui/i], [MODEL, [VENDOR, "Nvidia"], [TYPE, CONSOLE]], [/(playstation [345portablevi]+)/i], [MODEL, [VENDOR, SONY], [TYPE, CONSOLE]], [/\b(xbox(?: one)?(?!; xbox))[\); ]/i], [MODEL, [VENDOR, MICROSOFT], [TYPE, CONSOLE]], [/smart-tv.+(samsung)/i], [VENDOR, [TYPE, SMARTTV]], [/hbbtv.+maple;(\d+)/i], [[MODEL, /^/, "SmartTV"], [VENDOR, SAMSUNG], [TYPE, SMARTTV]], [/(nux; netcast.+smarttv|lg (netcast\.tv-201\d|android tv))/i], [[VENDOR, LG], [TYPE, SMARTTV]], [/(apple) ?tv/i], [VENDOR, [MODEL, APPLE + " TV"], [TYPE, SMARTTV]], [/crkey/i], [[MODEL, CHROME + "cast"], [VENDOR, GOOGLE], [TYPE, SMARTTV]], [/droid.+aft(\w)( bui|\))/i], [MODEL, [VENDOR, AMAZON], [TYPE, SMARTTV]], [/\(dtv[\);].+(aquos)/i], [MODEL, [VENDOR, "Sharp"], [TYPE, SMARTTV]], [/\b(roku)[\dx]*[\)\/]((?:dvp-)?[\d\.]*)/i, /hbbtv\/\d+\.\d+\.\d+ +\([\w ]*; *(\w[^;]*);([^;]*)/i], [[VENDOR, trim], [MODEL, trim], [TYPE, SMARTTV]], [/\b(android tv|smart[- ]?tv|opera tv|tv; rv:)\b/i], [[TYPE, SMARTTV]], [/((pebble))app/i], [VENDOR, MODEL, [TYPE, WEARABLE]], [/droid.+; (glass) \d/i], [MODEL, [VENDOR, GOOGLE], [TYPE, WEARABLE]], [/droid.+; (wt63?0{2,3})\)/i], [MODEL, [VENDOR, ZEBRA], [TYPE, WEARABLE]], [/(quest( 2)?)/i], [MODEL, [VENDOR, FACEBOOK], [TYPE, WEARABLE]], [/(tesla)(?: qtcarbrowser|\/[-\w\.]+)/i], [VENDOR, [TYPE, EMBEDDED]], [/droid .+?; ([^;]+?)(?: bui|\) applew).+? mobile safari/i], [MODEL, [TYPE, MOBILE]], [/droid .+?; ([^;]+?)(?: bui|\) applew).+?(?! mobile) safari/i], [MODEL, [TYPE, TABLET]], [/\b((tablet|tab)[;\/]|focus\/\d(?!.+mobile))/i], [[TYPE, TABLET]], [/(phone|mobile(?:[;\/]| safari)|pda(?=.+windows ce))/i], [[TYPE, MOBILE]], [/(android[-\w\. ]{0,9});.+buil/i], [MODEL, [VENDOR, "Generic"]]], engine: [[/windows.+ edge\/([\w\.]+)/i], [VERSION, [NAME, EDGE + "HTML"]], [/webkit\/537\.36.+chrome\/(?!27)([\w\.]+)/i], [VERSION, [NAME, "Blink"]], [/(presto)\/([\w\.]+)/i, /(webkit|trident|netfront|netsurf|amaya|lynx|w3m|goanna)\/([\w\.]+)/i, /ekioh(flow)\/([\w\.]+)/i, /(khtml|tasman|links)[\/ ]\(?([\w\.]+)/i, /(icab)[\/ ]([23]\.[\d\.]+)/i], [NAME, VERSION], [/rv\:([\w\.]{1,9})\b.+(gecko)/i], [VERSION, NAME]], os: [[/microsoft (windows) (vista|xp)/i], [NAME, VERSION], [/(windows) nt 6\.2; (arm)/i, /(windows (?:phone(?: os)?|mobile))[\/ ]?([\d\.\w ]*)/i, /(windows)[\/ ]?([ntce\d\. ]+\w)(?!.+xbox)/i], [NAME, [VERSION, strMapper, windowsVersionMap]], [/(win(?=3|9|n)|win 9x )([nt\d\.]+)/i], [[NAME, "Windows"], [VERSION, strMapper, windowsVersionMap]], [/ip[honead]{2,4}\b(?:.*os ([\w]+) like mac|; opera)/i, /cfnetwork\/.+darwin/i], [[VERSION, /_/g, "."], [NAME, "iOS"]], [/(mac os x) ?([\w\. ]*)/i, /(macintosh|mac_powerpc\b)(?!.+haiku)/i], [[NAME, "Mac OS"], [VERSION, /_/g, "."]], [/droid ([\w\.]+)\b.+(android[- ]x86)/i], [VERSION, NAME], [/(android|webos|qnx|bada|rim tablet os|maemo|meego|sailfish)[-\/ ]?([\w\.]*)/i, /(blackberry)\w*\/([\w\.]*)/i, /(tizen|kaios)[\/ ]([\w\.]+)/i, /\((series40);/i], [NAME, VERSION], [/\(bb(10);/i], [VERSION, [NAME, BLACKBERRY]], [/(?:symbian ?os|symbos|s60(?=;)|series60)[-\/ ]?([\w\.]*)/i], [VERSION, [NAME, "Symbian"]], [/mozilla\/[\d\.]+ \((?:mobile|tablet|tv|mobile; [\w ]+); rv:.+ gecko\/([\w\.]+)/i], [VERSION, [NAME, FIREFOX + " OS"]], [/web0s;.+rt(tv)/i, /\b(?:hp)?wos(?:browser)?\/([\w\.]+)/i], [VERSION, [NAME, "webOS"]], [/crkey\/([\d\.]+)/i], [VERSION, [NAME, CHROME + "cast"]], [/(cros) [\w]+ ([\w\.]+\w)/i], [[NAME, "Chromium OS"], VERSION], [/(nintendo|playstation) ([wids345portablevuch]+)/i, /(xbox); +xbox ([^\);]+)/i, /\b(joli|palm)\b ?(?:os)?\/?([\w\.]*)/i, /(mint)[\/\(\) ]?(\w*)/i, /(mageia|vectorlinux)[; ]/i, /([kxln]?ubuntu|debian|suse|opensuse|gentoo|arch(?= linux)|slackware|fedora|mandriva|centos|pclinuxos|red ?hat|zenwalk|linpus|raspbian|plan 9|minix|risc os|contiki|deepin|manjaro|elementary os|sabayon|linspire)(?: gnu\/linux)?(?: enterprise)?(?:[- ]linux)?(?:-gnu)?[-\/ ]?(?!chrom|package)([-\w\.]*)/i, /(hurd|linux) ?([\w\.]*)/i, /(gnu) ?([\w\.]*)/i, /\b([-frentopcghs]{0,5}bsd|dragonfly)[\/ ]?(?!amd|[ix346]{1,2}86)([\w\.]*)/i, /(haiku) (\w+)/i], [NAME, VERSION], [/(sunos) ?([\w\.\d]*)/i], [[NAME, "Solaris"], VERSION], [/((?:open)?solaris)[-\/ ]?([\w\.]*)/i, /(aix) ((\d)(?=\.|\)| )[\w\.])*/i, /\b(beos|os\/2|amigaos|morphos|openvms|fuchsia|hp-ux)/i, /(unix) ?([\w\.]*)/i], [NAME, VERSION]] }; var UAParser = function (ua, extensions) { if (typeof ua === OBJ_TYPE) { extensions = ua; ua = undefined } if (!(this instanceof UAParser)) { return new UAParser(ua, extensions).getResult() } var _ua = ua || (typeof window !== UNDEF_TYPE && window.navigator && window.navigator.userAgent ? window.navigator.userAgent : EMPTY); var _rgxmap = extensions ? extend(regexes, extensions) : regexes; this.getBrowser = function () { var _browser = {}; _browser[NAME] = undefined; _browser[VERSION] = undefined; rgxMapper.call(_browser, _ua, _rgxmap.browser); _browser.major = majorize(_browser.version); return _browser }; this.getCPU = function () { var _cpu = {}; _cpu[ARCHITECTURE] = undefined; rgxMapper.call(_cpu, _ua, _rgxmap.cpu); return _cpu }; this.getDevice = function () { var _device = {}; _device[VENDOR] = undefined; _device[MODEL] = undefined; _device[TYPE] = undefined; rgxMapper.call(_device, _ua, _rgxmap.device); return _device }; this.getEngine = function () { var _engine = {}; _engine[NAME] = undefined; _engine[VERSION] = undefined; rgxMapper.call(_engine, _ua, _rgxmap.engine); return _engine }; this.getOS = function () { var _os = {}; _os[NAME] = undefined; _os[VERSION] = undefined; rgxMapper.call(_os, _ua, _rgxmap.os); return _os }; this.getResult = function () { return { ua: this.getUA(), browser: this.getBrowser(), engine: this.getEngine(), os: this.getOS(), device: this.getDevice(), cpu: this.getCPU() } }; this.getUA = function () { return _ua }; this.setUA = function (ua) { _ua = typeof ua === STR_TYPE && ua.length > UA_MAX_LENGTH ? trim(ua, UA_MAX_LENGTH) : ua; return this }; this.setUA(_ua); return this }; UAParser.VERSION = LIBVERSION; UAParser.BROWSER = enumerize([NAME, VERSION, MAJOR]); UAParser.CPU = enumerize([ARCHITECTURE]); UAParser.DEVICE = enumerize([MODEL, VENDOR, TYPE, CONSOLE, MOBILE, SMARTTV, TABLET, WEARABLE, EMBEDDED]); UAParser.ENGINE = UAParser.OS = enumerize([NAME, VERSION]); if (typeof exports !== UNDEF_TYPE) { if (typeof module !== UNDEF_TYPE && module.exports) { exports = module.exports = UAParser } exports.UAParser = UAParser } else { if (typeof define === FUNC_TYPE && define.amd) { define(function () { return UAParser }) } else if (typeof window !== UNDEF_TYPE) { window.UAParser = UAParser } } var $ = typeof window !== UNDEF_TYPE && (window.jQuery || window.Zepto); if ($ && !$.ua) { var parser = new UAParser; $.ua = parser.getResult(); $.ua.get = function () { return parser.getUA() }; $.ua.set = function (ua) { parser.setUA(ua); var result = parser.getResult(); for (var prop in result) { $.ua[prop] = result[prop] } } } })(typeof window === "object" ? window : this); \ No newline at end of file diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 50d1ecd0..5435c29a 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -1,11 +1,16 @@ const Path = require('path') const date = require('date-and-time') +const serverVersion = require('../../package.json').version const { PlayMethod } = require('../utils/constants') const PlaybackSession = require('../objects/PlaybackSession') +const DeviceInfo = require('../objects/DeviceInfo') const Stream = require('../objects/Stream') const Logger = require('../Logger') const fs = require('fs-extra') +const uaParserJs = require('../libs/uaParserJs') +const requestIp = require('../libs/requestIp') + class PlaybackSessionManager { constructor(db, emitter, clientEmitter) { this.db = db @@ -27,8 +32,21 @@ class PlaybackSessionManager { return session ? session.stream : null } - async startSessionRequest(user, libraryItem, episodeId, options, res) { - const session = await this.startSession(user, libraryItem, episodeId, options) + getDeviceInfo(req) { + const ua = uaParserJs(req.headers['user-agent']) + const ip = requestIp.getClientIp(req) + const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client + + const deviceInfo = new DeviceInfo() + deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion) + return deviceInfo + } + + async startSessionRequest(req, res, episodeId) { + const deviceInfo = this.getDeviceInfo(req) + + const { user, libraryItem, body: options } = req + const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options) res.json(session.toJSONForClient(libraryItem)) } @@ -84,7 +102,7 @@ class PlaybackSessionManager { res.sendStatus(200) } - async startSession(user, libraryItem, episodeId, options) { + async startSession(user, deviceInfo, libraryItem, episodeId, options) { // Close any sessions already open for user var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id) for (const session of userSessions) { @@ -99,7 +117,7 @@ class PlaybackSessionManager { var userStartTime = 0 if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0 const newPlaybackSession = new PlaybackSession() - newPlaybackSession.setData(libraryItem, user, mediaPlayer, episodeId) + newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId) var audioTracks = [] if (shouldDirectPlay) { @@ -122,7 +140,6 @@ class PlaybackSessionManager { }) } - newPlaybackSession.currentTime = userStartTime newPlaybackSession.audioTracks = audioTracks // Will save on the first sync diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js new file mode 100644 index 00000000..2f97d2ad --- /dev/null +++ b/server/objects/DeviceInfo.js @@ -0,0 +1,74 @@ +class DeviceInfo { + constructor(deviceInfo = null) { + this.ipAddress = null + + // From User Agent (see: https://www.npmjs.com/package/ua-parser-js) + this.browserName = null + this.browserVersion = null + this.osName = null + this.osVersion = null + this.deviceType = null + + // From client + this.clientVersion = null + this.manufacturer = null + this.model = null + this.sdkVersion = null // Android Only + + this.serverVersion = null + + if (deviceInfo) { + this.construct(deviceInfo) + } + } + + construct(deviceInfo) { + for (const key in deviceInfo) { + if (deviceInfo[key] !== undefined && this[key] !== undefined) { + this[key] = deviceInfo[key] + } + } + } + + toJSON() { + const obj = { + ipAddress: this.ipAddress, + browserName: this.browserName, + browserVersion: this.browserVersion, + osName: this.osName, + osVersion: this.osVersion, + deviceType: this.deviceType, + clientVersion: this.clientVersion, + manufacturer: this.manufacturer, + model: this.model, + sdkVersion: this.sdkVersion, + serverVersion: this.serverVersion + } + for (const key in obj) { + if (obj[key] === null || obj[key] === undefined) { + delete obj[key] + } + } + return obj + } + + setData(ip, ua, clientDeviceInfo, serverVersion) { + this.ipAddress = ip || null + + const uaObj = ua || {} + this.browserName = uaObj.browser.name || null + this.browserVersion = uaObj.browser.version || null + this.osName = uaObj.os.name || null + this.osVersion = uaObj.os.version || null + this.deviceType = uaObj.device.type || null + + var cdi = clientDeviceInfo || {} + this.clientVersion = cdi.clientVersion || null + this.manufacturer = cdi.manufacturer || null + this.model = cdi.model || null + this.sdkVersion = cdi.sdkVersion || null + + this.serverVersion = serverVersion || null + } +} +module.exports = DeviceInfo \ No newline at end of file diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index 34311928..9dd7f83f 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -3,6 +3,7 @@ const { getId } = require('../utils/index') const { PlayMethod } = require('../utils/constants') const BookMetadata = require('./metadata/BookMetadata') const PodcastMetadata = require('./metadata/PodcastMetadata') +const DeviceInfo = require('./DeviceInfo') class PlaybackSession { constructor(session) { @@ -21,18 +22,21 @@ class PlaybackSession { this.playMethod = null this.mediaPlayer = null + this.deviceInfo = null this.date = null this.dayOfWeek = null this.timeListening = null + this.startTime = null // media current time at start of playback + this.currentTime = 0 // Last current time set + this.startedAt = null this.updatedAt = null // Not saved in DB this.lastSave = 0 this.audioTracks = [] - this.currentTime = 0 this.stream = null if (session) { @@ -56,10 +60,13 @@ class PlaybackSession { duration: this.duration, playMethod: this.playMethod, mediaPlayer: this.mediaPlayer, + deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null, date: this.date, dayOfWeek: this.dayOfWeek, timeListening: this.timeListening, - lastUpdate: this.lastUpdate, + startTime: this.startTime, + currentTime: this.currentTime, + startedAt: this.startedAt, updatedAt: this.updatedAt } } @@ -80,13 +87,15 @@ class PlaybackSession { duration: this.duration, playMethod: this.playMethod, mediaPlayer: this.mediaPlayer, + deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null, date: this.date, dayOfWeek: this.dayOfWeek, timeListening: this.timeListening, - lastUpdate: this.lastUpdate, + startTime: this.startTime, + currentTime: this.currentTime, + startedAt: this.startedAt, updatedAt: this.updatedAt, audioTracks: this.audioTracks.map(at => at.toJSON()), - currentTime: this.currentTime, libraryItem: libraryItem.toJSONExpanded() } } @@ -101,6 +110,7 @@ class PlaybackSession { this.duration = session.duration this.playMethod = session.playMethod this.mediaPlayer = session.mediaPlayer || null + this.deviceInfo = new DeviceInfo(session.deviceInfo) this.chapters = session.chapters || [] this.mediaMetadata = null @@ -118,6 +128,9 @@ class PlaybackSession { this.dayOfWeek = session.dayOfWeek this.timeListening = session.timeListening || null + this.startTime = session.startTime || 0 + this.currentTime = session.currentTime || 0 + this.startedAt = session.startedAt this.updatedAt = session.updatedAt || null } @@ -127,7 +140,7 @@ class PlaybackSession { return Math.max(0, Math.min(this.currentTime / this.duration, 1)) } - setData(libraryItem, user, mediaPlayer, episodeId = null) { + setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) { this.id = getId('play') this.userId = user.id this.libraryItemId = libraryItem.id @@ -146,8 +159,13 @@ class PlaybackSession { } this.mediaPlayer = mediaPlayer + this.deviceInfo = deviceInfo || new DeviceInfo() + this.timeListening = 0 + this.startTime = startTime + this.currentTime = startTime + this.date = date.format(new Date(), 'YYYY-MM-DD') this.dayOfWeek = date.format(new Date(), 'dddd') this.startedAt = Date.now() From b2aab06e0140fe472732e607ec9e0a6973116adf Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 27 May 2022 17:39:24 -0500 Subject: [PATCH 19/23] Add:Listening session modal with all details --- .../modals/ListeningSessionModal.vue | 150 ++++++++++++++++++ client/pages/config/stats.vue | 6 +- client/pages/config/users/_id/index.vue | 2 +- client/pages/config/users/_id/sessions.vue | 31 ++-- server/objects/PlaybackSession.js | 9 +- 5 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 client/components/modals/ListeningSessionModal.vue diff --git a/client/components/modals/ListeningSessionModal.vue b/client/components/modals/ListeningSessionModal.vue new file mode 100644 index 00000000..7c4166b6 --- /dev/null +++ b/client/components/modals/ListeningSessionModal.vue @@ -0,0 +1,150 @@ +<template> + <modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'"> + <template #outer> + <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> + <p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p> + </div> + </template> + <div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh"> + <div class="flex items-center"> + <p class="text-base text-gray-200">{{ _session.displayTitle }}</p> + <p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p> + </div> + + <div class="w-full h-px bg-white bg-opacity-10 my-4" /> + + <div class="flex flex-wrap mb-4"> + <div class="w-full md:w-2/3"> + <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">Details</p> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Started At</div> + <div class="px-1"> + {{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Updated At</div> + <div class="px-1"> + {{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Listened for</div> + <div class="px-1"> + {{ $elapsedPrettyExtended(_session.timeListening) }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Start Time</div> + <div class="px-1"> + {{ $secondsToTimestamp(_session.startTime) }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Last Time</div> + <div class="px-1"> + {{ $secondsToTimestamp(_session.currentTime) }} + </div> + </div> + + <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Item</p> + <div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Library Id</div> + <div class="px-1"> + {{ _session.libraryId }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Library Item Id</div> + <div class="px-1"> + {{ _session.libraryItemId }} + </div> + </div> + <div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Episode Id</div> + <div class="px-1"> + {{ _session.episodeId }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Media Type</div> + <div class="px-1"> + {{ _session.mediaType }} + </div> + </div> + <div class="flex items-center -mx-1 mb-1"> + <div class="w-40 px-1 text-gray-200">Duration</div> + <div class="px-1"> + {{ $elapsedPretty(_session.duration) }} + </div> + </div> + </div> + <div class="w-full md:w-1/3"> + <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">User</p> + <p class="mb-1">{{ _session.userId }}</p> + + <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Media Player</p> + <p class="mb-1">{{ playMethodName }}</p> + <p class="mb-1">{{ _session.mediaPlayer }}</p> + + <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p> + <p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p> + <p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p> + <p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p> + <p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p> + <p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p> + <p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p> + </div> + </div> + </div> + </modals-modal> +</template> + +<script> +export default { + props: { + value: Boolean, + session: { + type: Object, + default: () => {} + } + }, + data() { + return {} + }, + computed: { + show: { + get() { + return this.value + }, + set(val) { + this.$emit('input', val) + } + }, + _session() { + return this.session || {} + }, + deviceInfo() { + return this._session.deviceInfo || {} + }, + osDisplayName() { + if (!this.deviceInfo.osName) return null + return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}` + }, + clientDisplayName() { + if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null + return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}` + }, + playMethodName() { + const playMethod = this._session.playMethod + if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play' + else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode' + else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream' + else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local' + return 'Unknown' + } + }, + methods: {}, + mounted() {} +} +</script> \ No newline at end of file diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index ea8fa32e..9f37cfb5 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -37,7 +37,11 @@ <div class="flex flex-col md:flex-row overflow-hidden max-w-full"> <stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" /> <div class="w-80 my-6 mx-auto"> - <h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1> + <div class="flex mb-4 items-center"> + <h1 class="text-2xl font-book">Recent Sessions</h1> + <div class="flex-grow" /> + <ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">View All</ui-btn> + </div> <p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p> <template v-for="(item, index) in mostRecentListeningSessions"> <div :key="item.id" class="w-full py-0.5"> diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index 8799de85..98961b33 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -44,7 +44,7 @@ </div> <div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="py-2"> - <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Item Progress</h1> + <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1> <table v-if="mediaProgress.length" class="userAudiobooksTable"> <tr class="bg-primary bg-opacity-40"> <th class="w-16 text-left">Item</th> diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue index ddd4e803..93395bfc 100644 --- a/client/pages/config/users/_id/sessions.vue +++ b/client/pages/config/users/_id/sessions.vue @@ -17,24 +17,23 @@ <div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="py-2"> - <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1> + <h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions ({{ listeningSessions.length }})</h1> <table v-if="listeningSessions.length" class="userSessionsTable"> <tr class="bg-primary bg-opacity-40"> <th class="flex-grow text-left">Item</th> - <th class="w-40 text-left hidden md:table-cell">Play Method</th> + <th class="w-32 text-left hidden md:table-cell">Play Method</th> <th class="w-40 text-left hidden sm:table-cell">Device Info</th> - <th class="w-20">Listening Time</th> + <th class="w-20">Listened</th> <th class="w-20">Last Time</th> - <!-- <th class="w-40 hidden sm:table-cell">Started At</th> --> <th class="w-40 hidden sm:table-cell">Last Update</th> </tr> - <tr v-for="session in listeningSessions" :key="session.id"> + <tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> <td class="py-1"> <p class="text-sm text-gray-200">{{ session.displayTitle }}</p> <p class="text-xs text-gray-400">{{ session.displayAuthor }}</p> </td> <td class="hidden md:table-cell"> - <p class="text-xs">{{ getPlayMethodName(session.playMethod) }} with {{ session.mediaPlayer }}</p> + <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> </td> <td class="hidden sm:table-cell"> <p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" /> @@ -45,11 +44,6 @@ <td class="text-center"> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> </td> - <!-- <td class="text-center hidden sm:table-cell"> - <ui-tooltip v-if="session.startedAt" direction="top" :text="$formatDate(session.startedAt, 'MMMM do, yyyy HH:mm')"> - <p class="text-xs">{{ $dateDistanceFromNow(session.startedAt) }}</p> - </ui-tooltip> - </td> --> <td class="text-center hidden sm:table-cell"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')"> <p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p> @@ -60,6 +54,8 @@ <p v-else class="text-white text-opacity-50">No sessions yet...</p> </div> </div> + + <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" /> </div> </template> @@ -77,6 +73,8 @@ export default { }, data() { return { + showSessionModal: false, + selectedSession: null, listeningSessions: [] } }, @@ -89,6 +87,10 @@ export default { } }, methods: { + showSession(session) { + this.selectedSession = session + this.showSessionModal = true + }, getDeviceInfoString(deviceInfo) { if (!deviceInfo) return '' var lines = [] @@ -127,12 +129,15 @@ export default { width: 100%; border: 1px solid #474747; } -.userSessionsTable tr:nth-child(even) { - background-color: #2e2e2e; +.userSessionsTable tr:first-child { + background-color: #272727; } .userSessionsTable tr:not(:first-child) { background-color: #373838; } +.userSessionsTable tr:not(:first-child):nth-child(odd) { + background-color: #2f2f2f; +} .userSessionsTable tr:hover:not(:first-child) { background-color: #474747; } diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index 9dd7f83f..5b69628a 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -9,6 +9,7 @@ class PlaybackSession { constructor(session) { this.id = null this.userId = null + this.libraryId = null this.libraryItemId = null this.episodeId = null @@ -47,8 +48,8 @@ class PlaybackSession { toJSON() { return { id: this.id, - sessionType: this.sessionType, userId: this.userId, + libraryId: this.libraryId, libraryItemId: this.libraryItemId, episodeId: this.episodeId, mediaType: this.mediaType, @@ -74,8 +75,8 @@ class PlaybackSession { toJSONForClient(libraryItem) { return { id: this.id, - sessionType: this.sessionType, userId: this.userId, + libraryId: this.libraryId, libraryItemId: this.libraryItemId, episodeId: this.episodeId, mediaType: this.mediaType, @@ -102,8 +103,8 @@ class PlaybackSession { construct(session) { this.id = session.id - this.sessionType = session.sessionType this.userId = session.userId + this.libraryId = session.libraryId || null this.libraryItemId = session.libraryItemId this.episodeId = session.episodeId this.mediaType = session.mediaType @@ -143,6 +144,7 @@ class PlaybackSession { setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) { this.id = getId('play') this.userId = user.id + this.libraryId = libraryItem.libraryId this.libraryItemId = libraryItem.id this.episodeId = episodeId this.mediaType = libraryItem.mediaType @@ -161,7 +163,6 @@ class PlaybackSession { this.mediaPlayer = mediaPlayer this.deviceInfo = deviceInfo || new DeviceInfo() - this.timeListening = 0 this.startTime = startTime this.currentTime = startTime From 96232676cb9f217212e531a913f66436527b7031 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 27 May 2022 17:50:56 -0500 Subject: [PATCH 20/23] Fix:Save RSS feed url passed in by user instead of using the RSS feed returned from the request #634 --- server/controllers/PodcastController.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index a0f14eda..41ab8f11 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -109,10 +109,8 @@ class PodcastController { return res.status(500).send('Invalid podcast RSS feed') } - if (!payload.podcast.metadata.feedUrl) { - // Not every RSS feed will put the feed url in their metadata - payload.podcast.metadata.feedUrl = url - } + // RSS feed may be a private RSS feed + payload.podcast.metadata.feedUrl = url res.json(payload) }).catch((error) => { From c4bfa266b0b46349fc697a11a8786ea6ac6cf36c Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 27 May 2022 19:41:40 -0500 Subject: [PATCH 21/23] Add:HTML sanitizer lib to support html in podcasts and replace strip html lib --- package-lock.json | 360 ++++----- package.json | 4 +- server/libs/sanitizeHtml.js | 874 ++++++++++++++++++++++ server/objects/entities/PodcastEpisode.js | 8 +- server/providers/Audible.js | 4 +- server/providers/iTunes.js | 8 +- server/utils/htmlSanitizer.js | 28 + server/utils/parseOpfMetadata.js | 5 +- server/utils/podcastUtils.js | 12 +- 9 files changed, 1051 insertions(+), 252 deletions(-) create mode 100644 server/libs/sanitizeHtml.js create mode 100644 server/utils/htmlSanitizer.js diff --git a/package-lock.json b/package-lock.json index 67d3c687..86ebcc86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,11 @@ { "name": "audiobookshelf", - "version": "2.0.14", + "version": "2.0.17", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "audiobookshelf", - "version": "2.0.14", + "version": "2.0.17", "license": "GPL-3.0", "dependencies": { "archiver": "^5.3.0", @@ -20,6 +19,7 @@ "fast-sort": "^3.1.1", "fluent-ffmpeg": "^2.1.2", "fs-extra": "^10.0.0", + "htmlparser2": "^8.0.1", "image-type": "^4.1.0", "jsonwebtoken": "^8.5.1", "libgen": "^2.1.0", @@ -31,24 +31,11 @@ "read-chunk": "^3.1.0", "recursive-readdir-async": "^1.1.8", "socket.io": "^4.4.1", - "string-strip-html": "^8.3.0", "watcher": "^1.2.0", "xml2js": "^0.4.23" }, "bin": { "audiobookshelf": "prod.js" - }, - "devDependencies": {} - }, - "node_modules/@babel/runtime": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", - "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", - "dependencies": { - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" } }, "node_modules/@sindresorhus/is": { @@ -625,6 +612,57 @@ "node": ">=4.5.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", + "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.1" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -711,6 +749,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/entities": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.3.0.tgz", + "integrity": "sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -994,10 +1043,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + "node_modules/htmlparser2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", + "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "entities": "^4.3.0" + } }, "node_modules/http-cache-semantics": { "version": "4.1.0", @@ -1225,11 +1287,6 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -1280,21 +1337,11 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, - "node_modules/lodash.trim": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz", - "integrity": "sha1-NkJefukL5KpeJ7zruFt9EepHqlc=" - }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=" }, - "node_modules/lodash.without": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz", - "integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw=" - }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -1625,44 +1672,6 @@ "node": ">= 0.6" } }, - "node_modules/ranges-apply": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-5.1.0.tgz", - "integrity": "sha512-VF3a0XUuYS/BQHv2RaIyX1K7S1hbfrs64hkGKgPVk0Y7p4XFwSucjTTttrBqmkcmB/PZx5ISTZdxErRZi/89aQ==", - "dependencies": { - "@babel/runtime": "^7.14.0", - "ranges-merge": "^7.1.0" - } - }, - "node_modules/ranges-merge": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-7.1.0.tgz", - "integrity": "sha512-coTHcyAEIhoEdsBs9f5f+q0rmy7UHvS/5nfuXzuj5oLX/l/tbqM5uxRb6eh8WMdetXia3lK67ZO4tarH4ieulQ==", - "dependencies": { - "@babel/runtime": "^7.14.0", - "ranges-push": "^5.1.0", - "ranges-sort": "^4.1.0" - } - }, - "node_modules/ranges-push": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-5.1.0.tgz", - "integrity": "sha512-vqGcaGq7GWV1zBa9w83E+dzYkOvE9/3pIRUPvLf12c+mGQCf1nesrkBI7Ob8taN2CC9V1HDSJx0KAQl0SgZftA==", - "dependencies": { - "@babel/runtime": "^7.14.0", - "ranges-merge": "^7.1.0", - "string-collapse-leading-whitespace": "^5.1.0", - "string-trim-spaces-only": "^3.1.0" - } - }, - "node_modules/ranges-sort": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-4.1.0.tgz", - "integrity": "sha512-GOQgk6UtsrfKFeYa53YLiBVnLINwYmOk5l2QZG1csZpT6GdImUwooh+/cRrp7b+fYawZX/rnyA3Ul+pdgQBIzA==", - "dependencies": { - "@babel/runtime": "^7.14.0" - } - }, "node_modules/raw-body": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", @@ -1718,11 +1727,6 @@ "node": ">=7.6" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -1987,52 +1991,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, - "node_modules/string-collapse-leading-whitespace": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-5.1.0.tgz", - "integrity": "sha512-mYz9/Kb5uvRB4DZj46zILwI4y9lD9JsvXG9Xb7zjbwm0I/R40G7oFfMsqJ28l2d7gWMTLJL569NfJQVLQbnHCw==", - "dependencies": { - "@babel/runtime": "^7.14.0" - } - }, "node_modules/string-indexes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz", "integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw==" }, - "node_modules/string-left-right": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-4.1.0.tgz", - "integrity": "sha512-ic/WvfNVUygWWsgg8akzSzp2NuttfhrdbH7QmSnda5b5RFmT9aCEDiS/M+gmTJwtFy7+b/2AXU4Z6vejcePQqQ==", - "dependencies": { - "@babel/runtime": "^7.14.0", - "lodash.clonedeep": "^4.5.0", - "lodash.isplainobject": "^4.0.6" - } - }, - "node_modules/string-strip-html": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-8.3.0.tgz", - "integrity": "sha512-1+rjTPt0JjpFr1w0bfNL1S6O0I9fJDqM+P3pFTpC6eEEpIXhmBvPLnaQoEuWarswiH219qCefDSxTLxGQyHKUg==", - "dependencies": { - "@babel/runtime": "^7.14.0", - "html-entities": "^2.3.2", - "lodash.isplainobject": "^4.0.6", - "lodash.trim": "^4.5.1", - "lodash.without": "^4.4.0", - "ranges-apply": "^5.1.0", - "ranges-push": "^5.1.0", - "string-left-right": "^4.1.0" - } - }, - "node_modules/string-trim-spaces-only": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-3.1.0.tgz", - "integrity": "sha512-AW7RSi3+QtE6wR+4m/kmwlyy39neBbCIzrzzu1/RGzNRiPKQOeB3rGzr4ubg4UIQgYtr2w0PrxhKPXgyqJ0vaQ==", - "dependencies": { - "@babel/runtime": "^7.14.0" - } - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -2229,14 +2192,6 @@ } }, "dependencies": { - "@babel/runtime": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", - "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, "@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2683,6 +2638,39 @@ "streamsearch": "0.1.2" } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", + "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.1" + } + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2751,6 +2739,11 @@ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==" }, + "entities": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.3.0.tgz", + "integrity": "sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==" + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2960,10 +2953,16 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, - "html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + "htmlparser2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", + "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "entities": "^4.3.0" + } }, "http-cache-semantics": { "version": "4.1.0", @@ -3154,11 +3153,6 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, "lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -3209,21 +3203,11 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, - "lodash.trim": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz", - "integrity": "sha1-NkJefukL5KpeJ7zruFt9EepHqlc=" - }, "lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=" }, - "lodash.without": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz", - "integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw=" - }, "lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -3451,44 +3435,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, - "ranges-apply": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-5.1.0.tgz", - "integrity": "sha512-VF3a0XUuYS/BQHv2RaIyX1K7S1hbfrs64hkGKgPVk0Y7p4XFwSucjTTttrBqmkcmB/PZx5ISTZdxErRZi/89aQ==", - "requires": { - "@babel/runtime": "^7.14.0", - "ranges-merge": "^7.1.0" - } - }, - "ranges-merge": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-7.1.0.tgz", - "integrity": "sha512-coTHcyAEIhoEdsBs9f5f+q0rmy7UHvS/5nfuXzuj5oLX/l/tbqM5uxRb6eh8WMdetXia3lK67ZO4tarH4ieulQ==", - "requires": { - "@babel/runtime": "^7.14.0", - "ranges-push": "^5.1.0", - "ranges-sort": "^4.1.0" - } - }, - "ranges-push": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-5.1.0.tgz", - "integrity": "sha512-vqGcaGq7GWV1zBa9w83E+dzYkOvE9/3pIRUPvLf12c+mGQCf1nesrkBI7Ob8taN2CC9V1HDSJx0KAQl0SgZftA==", - "requires": { - "@babel/runtime": "^7.14.0", - "ranges-merge": "^7.1.0", - "string-collapse-leading-whitespace": "^5.1.0", - "string-trim-spaces-only": "^3.1.0" - } - }, - "ranges-sort": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-4.1.0.tgz", - "integrity": "sha512-GOQgk6UtsrfKFeYa53YLiBVnLINwYmOk5l2QZG1csZpT6GdImUwooh+/cRrp7b+fYawZX/rnyA3Ul+pdgQBIzA==", - "requires": { - "@babel/runtime": "^7.14.0" - } - }, "raw-body": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", @@ -3532,11 +3478,6 @@ "resolved": "https://registry.npmjs.org/recursive-readdir-async/-/recursive-readdir-async-1.2.1.tgz", "integrity": "sha512-fU8aySmHIhrycTlXn+hI7dS/p7GnrMHzr2xDdBSd8HZ16mbLkmfIEccIE80gLHftrkTt9oDJiGEJNIPY6n0v6A==" }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, "resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -3748,52 +3689,11 @@ } } }, - "string-collapse-leading-whitespace": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-5.1.0.tgz", - "integrity": "sha512-mYz9/Kb5uvRB4DZj46zILwI4y9lD9JsvXG9Xb7zjbwm0I/R40G7oFfMsqJ28l2d7gWMTLJL569NfJQVLQbnHCw==", - "requires": { - "@babel/runtime": "^7.14.0" - } - }, "string-indexes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz", "integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw==" }, - "string-left-right": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-4.1.0.tgz", - "integrity": "sha512-ic/WvfNVUygWWsgg8akzSzp2NuttfhrdbH7QmSnda5b5RFmT9aCEDiS/M+gmTJwtFy7+b/2AXU4Z6vejcePQqQ==", - "requires": { - "@babel/runtime": "^7.14.0", - "lodash.clonedeep": "^4.5.0", - "lodash.isplainobject": "^4.0.6" - } - }, - "string-strip-html": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-8.3.0.tgz", - "integrity": "sha512-1+rjTPt0JjpFr1w0bfNL1S6O0I9fJDqM+P3pFTpC6eEEpIXhmBvPLnaQoEuWarswiH219qCefDSxTLxGQyHKUg==", - "requires": { - "@babel/runtime": "^7.14.0", - "html-entities": "^2.3.2", - "lodash.isplainobject": "^4.0.6", - "lodash.trim": "^4.5.1", - "lodash.without": "^4.4.0", - "ranges-apply": "^5.1.0", - "ranges-push": "^5.1.0", - "string-left-right": "^4.1.0" - } - }, - "string-trim-spaces-only": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-3.1.0.tgz", - "integrity": "sha512-AW7RSi3+QtE6wR+4m/kmwlyy39neBbCIzrzzu1/RGzNRiPKQOeB3rGzr4ubg4UIQgYtr2w0PrxhKPXgyqJ0vaQ==", - "requires": { - "@babel/runtime": "^7.14.0" - } - }, "tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", diff --git a/package.json b/package.json index 151d1cd1..fab42a18 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "fast-sort": "^3.1.1", "fluent-ffmpeg": "^2.1.2", "fs-extra": "^10.0.0", + "htmlparser2": "^8.0.1", "image-type": "^4.1.0", "jsonwebtoken": "^8.5.1", "libgen": "^2.1.0", @@ -49,8 +50,7 @@ "read-chunk": "^3.1.0", "recursive-readdir-async": "^1.1.8", "socket.io": "^4.4.1", - "string-strip-html": "^8.3.0", "watcher": "^1.2.0", "xml2js": "^0.4.23" } -} \ No newline at end of file +} diff --git a/server/libs/sanitizeHtml.js b/server/libs/sanitizeHtml.js new file mode 100644 index 00000000..3fee985e --- /dev/null +++ b/server/libs/sanitizeHtml.js @@ -0,0 +1,874 @@ +/* + sanitize-html (Apostrophe Technologies) + SOURCE: https://github.com/apostrophecms/sanitize-html + LICENSE: https://github.com/apostrophecms/sanitize-html/blob/main/LICENSE + + Modified for audiobookshelf +*/ + +const htmlparser = require('htmlparser2'); +// const escapeStringRegexp = require('escape-string-regexp'); +// const { isPlainObject } = require('is-plain-object'); +// const deepmerge = require('deepmerge'); +// const parseSrcset = require('parse-srcset'); +// const { parse: postcssParse } = require('postcss'); +// Tags that can conceivably represent stand-alone media. + +// ABS UPDATE: Packages not necessary +// SOURCE: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js +function escapeStringRegexp(string) { + if (typeof string !== 'string') { + throw new TypeError('Expected a string'); + } + + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. + return string + .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + .replace(/-/g, '\\x2d'); +} + +// SOURCE: https://github.com/jonschlinkert/is-plain-object/blob/master/is-plain-object.js +function isObject(o) { + return Object.prototype.toString.call(o) === '[object Object]'; +} + +function isPlainObject(o) { + var ctor, prot; + + if (isObject(o) === false) return false; + + // If has modified constructor + ctor = o.constructor; + if (ctor === undefined) return true; + + // If has modified prototype + prot = ctor.prototype; + if (isObject(prot) === false) return false; + + // If constructor does not have an Object-specific method + if (prot.hasOwnProperty('isPrototypeOf') === false) { + return false; + } + + // Most likely a plain Object + return true; +}; + + +const mediaTags = [ + 'img', 'audio', 'video', 'picture', 'svg', + 'object', 'map', 'iframe', 'embed' +]; +// Tags that are inherently vulnerable to being used in XSS attacks. +const vulnerableTags = ['script', 'style']; + +function each(obj, cb) { + if (obj) { + Object.keys(obj).forEach(function (key) { + cb(obj[key], key); + }); + } +} + +// Avoid false positives with .__proto__, .hasOwnProperty, etc. +function has(obj, key) { + return ({}).hasOwnProperty.call(obj, key); +} + +// Returns those elements of `a` for which `cb(a)` returns truthy +function filter(a, cb) { + const n = []; + each(a, function (v) { + if (cb(v)) { + n.push(v); + } + }); + return n; +} + +function isEmptyObject(obj) { + for (const key in obj) { + if (has(obj, key)) { + return false; + } + } + return true; +} + +function stringifySrcset(parsedSrcset) { + return parsedSrcset.map(function (part) { + if (!part.url) { + throw new Error('URL missing'); + } + + return ( + part.url + + (part.w ? ` ${part.w}w` : '') + + (part.h ? ` ${part.h}h` : '') + + (part.d ? ` ${part.d}x` : '') + ); + }).join(', '); +} + +module.exports = sanitizeHtml; + +// A valid attribute name. +// We use a tolerant definition based on the set of strings defined by +// html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state +// and html.spec.whatwg.org/multipage/parsing.html#attribute-name-state . +// The characters accepted are ones which can be appended to the attribute +// name buffer without triggering a parse error: +// * unexpected-equals-sign-before-attribute-name +// * unexpected-null-character +// * unexpected-character-in-attribute-name +// We exclude the empty string because it's impossible to get to the after +// attribute name state with an empty attribute name buffer. +const VALID_HTML_ATTRIBUTE_NAME = /^[^\0\t\n\f\r /<=>]+$/; + +// Ignore the _recursing flag; it's there for recursive +// invocation as a guard against this exploit: +// https://github.com/fb55/htmlparser2/issues/105 + +function sanitizeHtml(html, options, _recursing) { + if (html == null) { + return ''; + } + + let result = ''; + // Used for hot swapping the result variable with an empty string in order to "capture" the text written to it. + let tempResult = ''; + + function Frame(tag, attribs) { + const that = this; + this.tag = tag; + this.attribs = attribs || {}; + this.tagPosition = result.length; + this.text = ''; // Node inner text + this.mediaChildren = []; + + this.updateParentNodeText = function () { + if (stack.length) { + const parentFrame = stack[stack.length - 1]; + parentFrame.text += that.text; + } + }; + + this.updateParentNodeMediaChildren = function () { + if (stack.length && mediaTags.includes(this.tag)) { + const parentFrame = stack[stack.length - 1]; + parentFrame.mediaChildren.push(this.tag); + } + }; + } + + options = Object.assign({}, sanitizeHtml.defaults, options); + options.parser = Object.assign({}, htmlParserDefaults, options.parser); + + // vulnerableTags + vulnerableTags.forEach(function (tag) { + if ( + options.allowedTags && options.allowedTags.indexOf(tag) > -1 && + !options.allowVulnerableTags + ) { + console.warn(`\n\n⚠️ Your \`allowedTags\` option includes, \`${tag}\`, which is inherently\nvulnerable to XSS attacks. Please remove it from \`allowedTags\`.\nOr, to disable this warning, add the \`allowVulnerableTags\` option\nand ensure you are accounting for this risk.\n\n`); + } + }); + + // Tags that contain something other than HTML, or where discarding + // the text when the tag is disallowed makes sense for other reasons. + // If we are not allowing these tags, we should drop their content too. + // For other tags you would drop the tag but keep its content. + const nonTextTagsArray = options.nonTextTags || [ + 'script', + 'style', + 'textarea', + 'option' + ]; + let allowedAttributesMap; + let allowedAttributesGlobMap; + if (options.allowedAttributes) { + allowedAttributesMap = {}; + allowedAttributesGlobMap = {}; + each(options.allowedAttributes, function (attributes, tag) { + allowedAttributesMap[tag] = []; + const globRegex = []; + attributes.forEach(function (obj) { + if (typeof obj === 'string' && obj.indexOf('*') >= 0) { + globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*')); + } else { + allowedAttributesMap[tag].push(obj); + } + }); + if (globRegex.length) { + allowedAttributesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$'); + } + }); + } + const allowedClassesMap = {}; + const allowedClassesGlobMap = {}; + const allowedClassesRegexMap = {}; + each(options.allowedClasses, function (classes, tag) { + // Implicitly allows the class attribute + if (allowedAttributesMap) { + if (!has(allowedAttributesMap, tag)) { + allowedAttributesMap[tag] = []; + } + allowedAttributesMap[tag].push('class'); + } + + allowedClassesMap[tag] = []; + allowedClassesRegexMap[tag] = []; + const globRegex = []; + classes.forEach(function (obj) { + if (typeof obj === 'string' && obj.indexOf('*') >= 0) { + globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*')); + } else if (obj instanceof RegExp) { + allowedClassesRegexMap[tag].push(obj); + } else { + allowedClassesMap[tag].push(obj); + } + }); + if (globRegex.length) { + allowedClassesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$'); + } + }); + + const transformTagsMap = {}; + let transformTagsAll; + each(options.transformTags, function (transform, tag) { + let transFun; + if (typeof transform === 'function') { + transFun = transform; + } else if (typeof transform === 'string') { + transFun = sanitizeHtml.simpleTransform(transform); + } + if (tag === '*') { + transformTagsAll = transFun; + } else { + transformTagsMap[tag] = transFun; + } + }); + + let depth; + let stack; + let skipMap; + let transformMap; + let skipText; + let skipTextDepth; + let addedText = false; + + initializeState(); + + const parser = new htmlparser.Parser({ + onopentag: function (name, attribs) { + // If `enforceHtmlBoundary` is `true` and this has found the opening + // `html` tag, reset the state. + if (options.enforceHtmlBoundary && name === 'html') { + initializeState(); + } + + if (skipText) { + skipTextDepth++; + return; + } + const frame = new Frame(name, attribs); + stack.push(frame); + + let skip = false; + const hasText = !!frame.text; + let transformedTag; + if (has(transformTagsMap, name)) { + transformedTag = transformTagsMap[name](name, attribs); + + frame.attribs = attribs = transformedTag.attribs; + + if (transformedTag.text !== undefined) { + frame.innerText = transformedTag.text; + } + + if (name !== transformedTag.tagName) { + frame.name = name = transformedTag.tagName; + transformMap[depth] = transformedTag.tagName; + } + } + if (transformTagsAll) { + transformedTag = transformTagsAll(name, attribs); + + frame.attribs = attribs = transformedTag.attribs; + if (name !== transformedTag.tagName) { + frame.name = name = transformedTag.tagName; + transformMap[depth] = transformedTag.tagName; + } + } + + if ((options.allowedTags && options.allowedTags.indexOf(name) === -1) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) { + skip = true; + skipMap[depth] = true; + if (options.disallowedTagsMode === 'discard') { + if (nonTextTagsArray.indexOf(name) !== -1) { + skipText = true; + skipTextDepth = 1; + } + } + skipMap[depth] = true; + } + depth++; + if (skip) { + if (options.disallowedTagsMode === 'discard') { + // We want the contents but not this tag + return; + } + tempResult = result; + result = ''; + } + result += '<' + name; + + if (name === 'script') { + if (options.allowedScriptHostnames || options.allowedScriptDomains) { + frame.innerText = ''; + } + } + + if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) { + each(attribs, function (value, a) { + if (!VALID_HTML_ATTRIBUTE_NAME.test(a)) { + // This prevents part of an attribute name in the output from being + // interpreted as the end of an attribute, or end of a tag. + delete frame.attribs[a]; + return; + } + let parsed; + // check allowedAttributesMap for the element and attribute and modify the value + // as necessary if there are specific values defined. + let passedAllowedAttributesMapCheck = false; + if (!allowedAttributesMap || + (has(allowedAttributesMap, name) && allowedAttributesMap[name].indexOf(a) !== -1) || + (allowedAttributesMap['*'] && allowedAttributesMap['*'].indexOf(a) !== -1) || + (has(allowedAttributesGlobMap, name) && allowedAttributesGlobMap[name].test(a)) || + (allowedAttributesGlobMap['*'] && allowedAttributesGlobMap['*'].test(a))) { + passedAllowedAttributesMapCheck = true; + } else if (allowedAttributesMap && allowedAttributesMap[name]) { + for (const o of allowedAttributesMap[name]) { + if (isPlainObject(o) && o.name && (o.name === a)) { + passedAllowedAttributesMapCheck = true; + let newValue = ''; + if (o.multiple === true) { + // verify the values that are allowed + const splitStrArray = value.split(' '); + for (const s of splitStrArray) { + if (o.values.indexOf(s) !== -1) { + if (newValue === '') { + newValue = s; + } else { + newValue += ' ' + s; + } + } + } + } else if (o.values.indexOf(value) >= 0) { + // verified an allowed value matches the entire attribute value + newValue = value; + } + value = newValue; + } + } + } + if (passedAllowedAttributesMapCheck) { + if (options.allowedSchemesAppliedToAttributes.indexOf(a) !== -1) { + if (naughtyHref(name, value)) { + delete frame.attribs[a]; + return; + } + } + + if (name === 'script' && a === 'src') { + + let allowed = true; + + try { + const parsed = new URL(value); + + if (options.allowedScriptHostnames || options.allowedScriptDomains) { + const allowedHostname = (options.allowedScriptHostnames || []).find(function (hostname) { + return hostname === parsed.hostname; + }); + const allowedDomain = (options.allowedScriptDomains || []).find(function (domain) { + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + }); + allowed = allowedHostname || allowedDomain; + } + } catch (e) { + allowed = false; + } + + if (!allowed) { + delete frame.attribs[a]; + return; + } + } + + if (name === 'iframe' && a === 'src') { + let allowed = true; + try { + // Chrome accepts \ as a substitute for / in the // at the + // start of a URL, so rewrite accordingly to prevent exploit. + // Also drop any whitespace at that point in the URL + value = value.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/, '$1//'); + if (value.startsWith('relative:')) { + // An attempt to exploit our workaround for base URLs being + // mandatory for relative URL validation in the WHATWG + // URL parser, reject it + throw new Error('relative: exploit attempt'); + } + // naughtyHref is in charge of whether protocol relative URLs + // are cool. Here we are concerned just with allowed hostnames and + // whether to allow relative URLs. + // + // Build a placeholder "base URL" against which any reasonable + // relative URL may be parsed successfully + let base = 'relative://relative-site'; + for (let i = 0; (i < 100); i++) { + base += `/${i}`; + } + const parsed = new URL(value, base); + const isRelativeUrl = parsed && parsed.hostname === 'relative-site' && parsed.protocol === 'relative:'; + if (isRelativeUrl) { + // default value of allowIframeRelativeUrls is true + // unless allowedIframeHostnames or allowedIframeDomains specified + allowed = has(options, 'allowIframeRelativeUrls') + ? options.allowIframeRelativeUrls + : (!options.allowedIframeHostnames && !options.allowedIframeDomains); + } else if (options.allowedIframeHostnames || options.allowedIframeDomains) { + const allowedHostname = (options.allowedIframeHostnames || []).find(function (hostname) { + return hostname === parsed.hostname; + }); + const allowedDomain = (options.allowedIframeDomains || []).find(function (domain) { + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + }); + allowed = allowedHostname || allowedDomain; + } + } catch (e) { + // Unparseable iframe src + allowed = false; + } + if (!allowed) { + delete frame.attribs[a]; + return; + } + } + if (a === 'srcset') { + delete frame.attribs[a]; + + // ABS UPDATE: srcset not necessary + // try { + // parsed = parseSrcset(value); + // parsed.forEach(function (value) { + // if (naughtyHref('srcset', value.url)) { + // value.evil = true; + // } + // }); + // parsed = filter(parsed, function (v) { + // return !v.evil; + // }); + // if (!parsed.length) { + // delete frame.attribs[a]; + // return; + // } else { + // value = stringifySrcset(filter(parsed, function (v) { + // return !v.evil; + // })); + // frame.attribs[a] = value; + // } + // } catch (e) { + // // Unparseable srcset + // delete frame.attribs[a]; + // return; + // } + } + if (a === 'class') { + const allowedSpecificClasses = allowedClassesMap[name]; + const allowedWildcardClasses = allowedClassesMap['*']; + const allowedSpecificClassesGlob = allowedClassesGlobMap[name]; + const allowedSpecificClassesRegex = allowedClassesRegexMap[name]; + const allowedWildcardClassesGlob = allowedClassesGlobMap['*']; + const allowedClassesGlobs = [ + allowedSpecificClassesGlob, + allowedWildcardClassesGlob + ] + .concat(allowedSpecificClassesRegex) + .filter(function (t) { + return t; + }); + if (allowedSpecificClasses && allowedWildcardClasses) { + // ABS UPDATE: classes and wildcard classes not necessary now + // value = filterClasses(value, deepmerge(allowedSpecificClasses, allowedWildcardClasses), allowedClassesGlobs); + } else { + value = filterClasses(value, allowedSpecificClasses || allowedWildcardClasses, allowedClassesGlobs); + } + if (!value.length) { + delete frame.attribs[a]; + return; + } + } + if (a === 'style') { + delete frame.attribs[a]; + + // ABS UPDATE: Styles not necessary + // try { + // const abstractSyntaxTree = postcssParse(name + ' {' + value + '}'); + // const filteredAST = filterCss(abstractSyntaxTree, options.allowedStyles); + + // value = stringifyStyleAttributes(filteredAST); + + // if (value.length === 0) { + // delete frame.attribs[a]; + // return; + // } + // } catch (e) { + // delete frame.attribs[a]; + // return; + // } + } + result += ' ' + a; + if (value && value.length) { + result += '="' + escapeHtml(value, true) + '"'; + } + } else { + delete frame.attribs[a]; + } + }); + } + if (options.selfClosing.indexOf(name) !== -1) { + result += ' />'; + } else { + result += '>'; + if (frame.innerText && !hasText && !options.textFilter) { + result += escapeHtml(frame.innerText); + addedText = true; + } + } + if (skip) { + result = tempResult + escapeHtml(result); + tempResult = ''; + } + }, + ontext: function (text) { + if (skipText) { + return; + } + const lastFrame = stack[stack.length - 1]; + let tag; + + if (lastFrame) { + tag = lastFrame.tag; + // If inner text was set by transform function then let's use it + text = lastFrame.innerText !== undefined ? lastFrame.innerText : text; + } + + if (options.disallowedTagsMode === 'discard' && ((tag === 'script') || (tag === 'style'))) { + // htmlparser2 gives us these as-is. Escaping them ruins the content. Allowing + // script tags is, by definition, game over for XSS protection, so if that's + // your concern, don't allow them. The same is essentially true for style tags + // which have their own collection of XSS vectors. + result += text; + } else { + const escaped = escapeHtml(text, false); + if (options.textFilter && !addedText) { + result += options.textFilter(escaped, tag); + } else if (!addedText) { + result += escaped; + } + } + if (stack.length) { + const frame = stack[stack.length - 1]; + frame.text += text; + } + }, + onclosetag: function (name) { + + if (skipText) { + skipTextDepth--; + if (!skipTextDepth) { + skipText = false; + } else { + return; + } + } + + const frame = stack.pop(); + if (!frame) { + // Do not crash on bad markup + return; + } + skipText = options.enforceHtmlBoundary ? name === 'html' : false; + depth--; + const skip = skipMap[depth]; + if (skip) { + delete skipMap[depth]; + if (options.disallowedTagsMode === 'discard') { + frame.updateParentNodeText(); + return; + } + tempResult = result; + result = ''; + } + + if (transformMap[depth]) { + name = transformMap[depth]; + delete transformMap[depth]; + } + + if (options.exclusiveFilter && options.exclusiveFilter(frame)) { + result = result.substr(0, frame.tagPosition); + return; + } + + frame.updateParentNodeMediaChildren(); + frame.updateParentNodeText(); + + if (options.selfClosing.indexOf(name) !== -1) { + // Already output /> + if (skip) { + result = tempResult; + tempResult = ''; + } + return; + } + + result += '</' + name + '>'; + if (skip) { + result = tempResult + escapeHtml(result); + tempResult = ''; + } + addedText = false; + } + }, options.parser); + parser.write(html); + parser.end(); + + return result; + + function initializeState() { + result = ''; + depth = 0; + stack = []; + skipMap = {}; + transformMap = {}; + skipText = false; + skipTextDepth = 0; + } + + function escapeHtml(s, quote) { + if (typeof (s) !== 'string') { + s = s + ''; + } + if (options.parser.decodeEntities) { + s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + if (quote) { + s = s.replace(/"/g, '"'); + } + } + // TODO: this is inadequate because it will pass `&0;`. This approach + // will not work, each & must be considered with regard to whether it + // is followed by a 100% syntactically valid entity or not, and escaped + // if it is not. If this bothers you, don't set parser.decodeEntities + // to false. (The default is true.) + s = s.replace(/&(?![a-zA-Z0-9#]{1,20};)/g, '&') // Match ampersands not part of existing HTML entity + .replace(/</g, '<') + .replace(/>/g, '>'); + if (quote) { + s = s.replace(/"/g, '"'); + } + return s; + } + + function naughtyHref(name, href) { + // Browsers ignore character codes of 32 (space) and below in a surprising + // number of situations. Start reading here: + // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab + // eslint-disable-next-line no-control-regex + href = href.replace(/[\x00-\x20]+/g, ''); + // Clobber any comments in URLs, which the browser might + // interpret inside an XML data island, allowing + // a javascript: URL to be snuck through + href = href.replace(/<!--.*?-->/g, ''); + // Case insensitive so we don't get faked out by JAVASCRIPT #1 + // Allow more characters after the first so we don't get faked + // out by certain schemes browsers accept + const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/); + if (!matches) { + // Protocol-relative URL starting with any combination of '/' and '\' + if (href.match(/^[/\\]{2}/)) { + return !options.allowProtocolRelative; + } + + // No scheme + return false; + } + const scheme = matches[1].toLowerCase(); + + if (has(options.allowedSchemesByTag, name)) { + return options.allowedSchemesByTag[name].indexOf(scheme) === -1; + } + + return !options.allowedSchemes || options.allowedSchemes.indexOf(scheme) === -1; + } + + /** + * Filters user input css properties by allowlisted regex attributes. + * Modifies the abstractSyntaxTree object. + * + * @param {object} abstractSyntaxTree - Object representation of CSS attributes. + * @property {array[Declaration]} abstractSyntaxTree.nodes[0] - Each object cointains prop and value key, i.e { prop: 'color', value: 'red' }. + * @param {object} allowedStyles - Keys are properties (i.e color), value is list of permitted regex rules (i.e /green/i). + * @return {object} - The modified tree. + */ + // function filterCss(abstractSyntaxTree, allowedStyles) { + // if (!allowedStyles) { + // return abstractSyntaxTree; + // } + + // const astRules = abstractSyntaxTree.nodes[0]; + // let selectedRule; + + // // Merge global and tag-specific styles into new AST. + // if (allowedStyles[astRules.selector] && allowedStyles['*']) { + // selectedRule = deepmerge( + // allowedStyles[astRules.selector], + // allowedStyles['*'] + // ); + // } else { + // selectedRule = allowedStyles[astRules.selector] || allowedStyles['*']; + // } + + // if (selectedRule) { + // abstractSyntaxTree.nodes[0].nodes = astRules.nodes.reduce(filterDeclarations(selectedRule), []); + // } + + // return abstractSyntaxTree; + // } + + /** + * Extracts the style attributes from an AbstractSyntaxTree and formats those + * values in the inline style attribute format. + * + * @param {AbstractSyntaxTree} filteredAST + * @return {string} - Example: "color:yellow;text-align:center !important;font-family:helvetica;" + */ + function stringifyStyleAttributes(filteredAST) { + return filteredAST.nodes[0].nodes + .reduce(function (extractedAttributes, attrObject) { + extractedAttributes.push( + `${attrObject.prop}:${attrObject.value}${attrObject.important ? ' !important' : ''}` + ); + return extractedAttributes; + }, []) + .join(';'); + } + + /** + * Filters the existing attributes for the given property. Discards any attributes + * which don't match the allowlist. + * + * @param {object} selectedRule - Example: { color: red, font-family: helvetica } + * @param {array} allowedDeclarationsList - List of declarations which pass the allowlist. + * @param {object} attributeObject - Object representing the current css property. + * @property {string} attributeObject.type - Typically 'declaration'. + * @property {string} attributeObject.prop - The CSS property, i.e 'color'. + * @property {string} attributeObject.value - The corresponding value to the css property, i.e 'red'. + * @return {function} - When used in Array.reduce, will return an array of Declaration objects + */ + function filterDeclarations(selectedRule) { + return function (allowedDeclarationsList, attributeObject) { + // If this property is allowlisted... + if (has(selectedRule, attributeObject.prop)) { + const matchesRegex = selectedRule[attributeObject.prop].some(function (regularExpression) { + return regularExpression.test(attributeObject.value); + }); + + if (matchesRegex) { + allowedDeclarationsList.push(attributeObject); + } + } + return allowedDeclarationsList; + }; + } + + function filterClasses(classes, allowed, allowedGlobs) { + if (!allowed) { + // The class attribute is allowed without filtering on this tag + return classes; + } + classes = classes.split(/\s+/); + return classes.filter(function (clss) { + return allowed.indexOf(clss) !== -1 || allowedGlobs.some(function (glob) { + return glob.test(clss); + }); + }).join(' '); + } +} + +// Defaults are accessible to you so that you can use them as a starting point +// programmatically if you wish + +const htmlParserDefaults = { + decodeEntities: true +}; +sanitizeHtml.defaults = { + allowedTags: [ + // Sections derived from MDN element categories and limited to the more + // benign categories. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element + // Content sectioning + 'address', 'article', 'aside', 'footer', 'header', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup', + 'main', 'nav', 'section', + // Text content + 'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure', + 'hr', 'li', 'main', 'ol', 'p', 'pre', 'ul', + // Inline text semantics + 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', + 'em', 'i', 'kbd', 'mark', 'q', + 'rb', 'rp', 'rt', 'rtc', 'ruby', + 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr', + // Table content + 'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th', + 'thead', 'tr' + ], + disallowedTagsMode: 'discard', + allowedAttributes: { + a: ['href', 'name', 'target'], + // We don't currently allow img itself by default, but + // these attributes would make sense if we did. + img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'] + }, + // Lots of these won't come up by default because we don't allow them + selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], + // URL schemes we permit + allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'], + allowedSchemesByTag: {}, + allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'], + allowProtocolRelative: true, + enforceHtmlBoundary: false +}; + +sanitizeHtml.simpleTransform = function (newTagName, newAttribs, merge) { + merge = (merge === undefined) ? true : merge; + newAttribs = newAttribs || {}; + + return function (tagName, attribs) { + let attrib; + if (merge) { + for (attrib in newAttribs) { + attribs[attrib] = newAttribs[attrib]; + } + } else { + attribs = newAttribs; + } + + return { + tagName: newTagName, + attribs: attribs + }; + }; +}; \ No newline at end of file diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 28c232d2..988421db 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,4 +1,3 @@ -const { stripHtml } = require('string-strip-html') const { getId } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -78,8 +77,7 @@ class PodcastEpisode { episodeType: this.episodeType, title: this.title, subtitle: this.subtitle, - // description: this.description, - description: this.descriptionPlain, // Temporary stripping HTML until proper cleaning is implemented + description: this.description, enclosure: this.enclosure ? { ...this.enclosure } : null, pubDate: this.pubDate, audioFile: this.audioFile.toJSON(), @@ -108,10 +106,6 @@ class PodcastEpisode { if (this.episode) return `${this.episode} - ${this.title}` return this.title } - get descriptionPlain() { - if (!this.description) return '' - return stripHtml(this.description).result - } setData(data, index = 1) { this.id = getId('ep') diff --git a/server/providers/Audible.js b/server/providers/Audible.js index c6d6836e..441e0dcd 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -1,5 +1,5 @@ const axios = require('axios') -const { stripHtml } = require('string-strip-html') +const htmlSanitizer = require('../utils/htmlSanitizer') const Logger = require('../Logger') class Audible { @@ -17,7 +17,7 @@ class Audible { narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null, publisher: publisher_name, publishedYear: release_date ? release_date.split('-')[0] : null, - description: publisher_summary ? stripHtml(publisher_summary).result : null, + description: publisher_summary ? htmlSanitizer.stripAllTags(publisher_summary) : null, cover: this.getBestImageLink(product_images), asin, series: primarySeries ? primarySeries.title : null, diff --git a/server/providers/iTunes.js b/server/providers/iTunes.js index 7f43d819..5bf33fe5 100644 --- a/server/providers/iTunes.js +++ b/server/providers/iTunes.js @@ -1,6 +1,7 @@ const axios = require('axios') const Logger = require('../Logger') -const { stripHtml } = require('string-strip-html') +const htmlSanitizer = require('../utils/htmlSanitizer') + class iTunes { constructor() { } @@ -64,7 +65,7 @@ class iTunes { artistId: data.artistId, title: data.collectionName, author: data.artistName, - description: stripHtml(data.description || '').result, + description: htmlSanitizer.stripAllTags(data.description || ''), publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null, genres: data.primaryGenreName ? [data.primaryGenreName] : [], cover: this.getCoverArtwork(data) @@ -83,7 +84,8 @@ class iTunes { artistId: data.artistId || null, title: data.collectionName, artistName: data.artistName, - description: stripHtml(data.description || '').result, + description: htmlSanitizer.sanitize(data.description || ''), + descriptionPlain: htmlSanitizer.stripAllTags(data.description || ''), releaseDate: data.releaseDate, genres: data.genres || [], cover: this.getCoverArtwork(data), diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js new file mode 100644 index 00000000..81ccdcff --- /dev/null +++ b/server/utils/htmlSanitizer.js @@ -0,0 +1,28 @@ +const sanitizeHtml = require('../libs/sanitizeHtml') + +function sanitize(html) { + const sanitizerOptions = { + allowedTags: [ + 'p', 'ol', 'ul', 'a', 'strong', 'em' + ], + disallowedTagsMode: 'discard', + allowedAttributes: { + a: ['href', 'name', 'target'] + }, + allowedSchemes: ['https'], + allowProtocolRelative: false + } + + return sanitizeHtml(html, sanitizerOptions) +} +module.exports.sanitize = sanitize + +function stripAllTags(html) { + const sanitizerOptions = { + allowedTags: [], + disallowedTagsMode: 'discard' + } + + return sanitizeHtml(html, sanitizerOptions) +} +module.exports.stripAllTags = stripAllTags \ No newline at end of file diff --git a/server/utils/parseOpfMetadata.js b/server/utils/parseOpfMetadata.js index 95d74775..ceac6047 100644 --- a/server/utils/parseOpfMetadata.js +++ b/server/utils/parseOpfMetadata.js @@ -1,5 +1,5 @@ const { xmlToJSON } = require('./index') -const { stripHtml } = require("string-strip-html") +const htmlSanitizer = require('./htmlSanitizer') function parseCreators(metadata) { if (!metadata['dc:creator']) return null @@ -57,8 +57,7 @@ function fetchDescription(metadata) { // check if description is HTML or plain text. only plain text allowed // calibre stores < and > as < and > description = description.replace(/</g, '<').replace(/>/g, '>') - if (description.match(/<!DOCTYPE html>|<\/?\s*[a-z-][^>]*\s*>|(\&(?:[\w\d]+|#\d+|#x[a-f\d]+);)/)) return stripHtml(description).result - return description + return htmlSanitizer.stripAllTags(description) } function fetchGenres(metadata) { diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 28c874cc..c29f02e9 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -1,6 +1,6 @@ const Logger = require('../Logger') const { xmlToJSON } = require('./index') -const { stripHtml } = require('string-strip-html') +const htmlSanitizer = require('../utils/htmlSanitizer') function extractFirstArrayItem(json, key) { if (!json[key] || !json[key].length) return null @@ -55,8 +55,9 @@ function extractPodcastMetadata(channel) { } if (channel['description']) { - metadata.description = extractFirstArrayItem(channel, 'description') - metadata.descriptionPlain = stripHtml(metadata.description || '').result + const rawDescription = extractFirstArrayItem(channel, 'description') || '' + metadata.description = htmlSanitizer.sanitize(rawDescription) + metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription) } var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link'] @@ -81,8 +82,9 @@ function extractEpisodeData(item) { } if (item['description']) { - episode.description = extractFirstArrayItem(item, 'description') - episode.descriptionPlain = stripHtml(episode.description || '').result + const rawDescription = extractFirstArrayItem(item, 'description') || '' + episode.description = htmlSanitizer.sanitize(rawDescription) + episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription) } var arrayFields = ['title', 'pubDate', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] From a394f38fe990a339837d1d57eeaaf078b3ef60d0 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 May 2022 11:38:51 -0500 Subject: [PATCH 22/23] Add:Full podcast episode description parsed and viewable in modal #492 --- client/assets/app.css | 1 + client/assets/defaultStyles.css | 44 +++++++++++ .../components/modals/podcast/ViewEpisode.vue | 75 +++++++++++++++++++ .../tables/podcast/EpisodeTableRow.vue | 17 +++-- .../tables/podcast/EpisodesTable.vue | 7 +- client/layouts/default.vue | 1 + client/store/globals.js | 4 + server/utils/htmlSanitizer.js | 2 +- server/utils/podcastUtils.js | 9 ++- 9 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 client/assets/defaultStyles.css create mode 100644 client/components/modals/podcast/ViewEpisode.vue diff --git a/client/assets/app.css b/client/assets/app.css index c323a37b..5d67df53 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -1,6 +1,7 @@ @import './fonts.css'; @import './transitions.css'; @import './draggable.css'; +@import './defaultStyles.css'; :root { --bookshelf-texture-img: url(/textures/wood_default.jpg); diff --git a/client/assets/defaultStyles.css b/client/assets/defaultStyles.css new file mode 100644 index 00000000..a4bef5e2 --- /dev/null +++ b/client/assets/defaultStyles.css @@ -0,0 +1,44 @@ +/* + + This is for setting regular html styles for places where embedding HTML will be + like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup. + +*/ + +.default-style p { + display: block; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; +} + +.default-style a { + text-decoration: none; + color: #5985ff; +} + +.default-style ul { + display: block; + list-style: circle; + list-style-type: disc; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 40px; +} + +.default-style li { + display: list-item; + text-align: -webkit-match-parent; +} + +.default-style li::marker { + unicode-bidi: isolate; + font-variant-numeric: tabular-nums; + text-transform: none; + text-indent: 0px !important; + text-align: start !important; + text-align-last: start !important; +} \ No newline at end of file diff --git a/client/components/modals/podcast/ViewEpisode.vue b/client/components/modals/podcast/ViewEpisode.vue new file mode 100644 index 00000000..04ab78c4 --- /dev/null +++ b/client/components/modals/podcast/ViewEpisode.vue @@ -0,0 +1,75 @@ +<template> + <modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing"> + <template #outer> + <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> + <p class="font-book text-3xl text-white truncate">Episode</p> + </div> + </template> + <div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh"> + <div class="flex mb-4"> + <div class="w-12 h-12"> + <covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" /> + </div> + <div class="flex-grow px-2"> + <p class="text-base mb-1">{{ podcastTitle }}</p> + <p class="text-xs text-gray-300">{{ podcastAuthor }}</p> + </div> + </div> + <p class="text-lg font-semibold mb-6">{{ title }}</p> + <div v-if="description" class="default-style" v-html="description" /> + <p v-else class="mb-2">No description</p> + </div> + </modals-modal> +</template> + +<script> +export default { + data() { + return { + processing: false + } + }, + computed: { + show: { + get() { + return this.$store.state.globals.showViewPodcastEpisodeModal + }, + set(val) { + this.$store.commit('globals/setShowViewPodcastEpisodeModal', val) + } + }, + libraryItem() { + return this.$store.state.selectedLibraryItem + }, + episode() { + return this.$store.state.globals.selectedEpisode || {} + }, + episodeId() { + return this.episode.id + }, + title() { + return this.episode.title || 'No Episode Title' + }, + description() { + return this.episode.description || '' + }, + media() { + return this.libraryItem ? this.libraryItem.media || {} : {} + }, + mediaMetadata() { + return this.media.metadata || {} + }, + podcastTitle() { + return this.mediaMetadata.title + }, + podcastAuthor() { + return this.mediaMetadata.author + }, + bookCoverAspectRatio() { + return this.$store.getters['getBookCoverAspectRatio'] + } + }, + methods: {}, + mounted() {} +} +</script> diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue index 0e318858..b7040098 100644 --- a/client/components/tables/podcast/EpisodeTableRow.vue +++ b/client/components/tables/podcast/EpisodeTableRow.vue @@ -1,16 +1,18 @@ <template> <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> - <div v-if="episode" class="flex items-center h-24"> + <div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)"> <div class="flex-grow px-2"> <p class="text-sm font-semibold"> {{ title }} </p> - <p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p> + + <p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p> + <div class="flex items-center pt-2"> - <div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick"> + <button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick"> <span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span> <p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p> - </div> + </button> <ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top"> <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" /> @@ -66,10 +68,11 @@ export default { title() { return this.episode.title || '' }, + subtitle() { + return this.episode.subtitle || '' + }, description() { - if (this.episode.subtitle) return this.episode.subtitle - var desc = this.episode.description || '' - return desc + return this.episode.description || '' }, duration() { return this.$secondsToTimestamp(this.episode.duration) diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/EpisodesTable.vue index 6401c893..300ecf0d 100644 --- a/client/components/tables/podcast/EpisodesTable.vue +++ b/client/components/tables/podcast/EpisodesTable.vue @@ -7,7 +7,7 @@ </div> <p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p> <template v-for="episode in episodesSorted"> - <tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" /> + <tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" /> </template> <modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" /> @@ -68,6 +68,11 @@ export default { this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) }, + viewEpisode(episode) { + this.$store.commit('setSelectedLibraryItem', this.libraryItem) + this.$store.commit('globals/setSelectedEpisode', episode) + this.$store.commit('globals/setShowViewPodcastEpisodeModal', true) + }, init() { this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) } diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 19e9b882..5c5ae4d1 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -14,6 +14,7 @@ <modals-edit-collection-modal /> <modals-bookshelf-texture-modal /> <modals-podcast-edit-episode /> + <modals-podcast-view-episode /> <modals-authors-edit-modal /> <readers-reader /> </div> diff --git a/client/store/globals.js b/client/store/globals.js index bbc383dd..16d1ef91 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -6,6 +6,7 @@ export const state = () => ({ showUserCollectionsModal: false, showEditCollectionModal: false, showEditPodcastEpisode: false, + showViewPodcastEpisodeModal: false, showEditAuthorModal: false, selectedEpisode: null, selectedCollection: null, @@ -53,6 +54,9 @@ export const mutations = { setShowEditPodcastEpisodeModal(state, val) { state.showEditPodcastEpisode = val }, + setShowViewPodcastEpisodeModal(state, val) { + state.showViewPodcastEpisodeModal = val + }, setEditCollection(state, collection) { state.selectedCollection = collection state.showEditCollectionModal = true diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js index 81ccdcff..cc046de8 100644 --- a/server/utils/htmlSanitizer.js +++ b/server/utils/htmlSanitizer.js @@ -3,7 +3,7 @@ const sanitizeHtml = require('../libs/sanitizeHtml') function sanitize(html) { const sanitizerOptions = { allowedTags: [ - 'p', 'ol', 'ul', 'a', 'strong', 'em' + 'p', 'ol', 'ul', 'li', 'a', 'strong', 'em' ], disallowedTagsMode: 'discard', allowedAttributes: { diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index c29f02e9..a5c2ab4e 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -81,9 +81,16 @@ function extractEpisodeData(item) { } } + // Full description with html + if (item['content:encoded']) { + const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim() + episode.description = htmlSanitizer.sanitize(rawDescription) + } + + // Supposed to be the plaintext description but not always followed if (item['description']) { const rawDescription = extractFirstArrayItem(item, 'description') || '' - episode.description = htmlSanitizer.sanitize(rawDescription) + if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription) episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription) } From 8b12508b0c5e6c56ead7b2f2e2f0cc7062fdbf68 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 May 2022 13:36:58 -0500 Subject: [PATCH 23/23] Add:Rich text editor for podcast episode description --- client/assets/defaultStyles.css | 11 + client/assets/trix.css | 563 ++++++++++++++++++ .../components/modals/podcast/EditEpisode.vue | 6 +- client/components/ui/RichTextEditor.vue | 75 +++ client/components/ui/VueTrix.vue | 284 +++++++++ client/package-lock.json | 15 +- client/package.json | 3 +- server/utils/htmlSanitizer.js | 2 +- 8 files changed, 952 insertions(+), 7 deletions(-) create mode 100644 client/assets/trix.css create mode 100644 client/components/ui/RichTextEditor.vue create mode 100644 client/components/ui/VueTrix.vue diff --git a/client/assets/defaultStyles.css b/client/assets/defaultStyles.css index a4bef5e2..027ccdf2 100644 --- a/client/assets/defaultStyles.css +++ b/client/assets/defaultStyles.css @@ -29,6 +29,17 @@ padding-inline-start: 40px; } +.default-style ol { + display: block; + list-style: decimal; + list-style-type: decimal; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 40px; +} + .default-style li { display: list-item; text-align: -webkit-match-parent; diff --git a/client/assets/trix.css b/client/assets/trix.css new file mode 100644 index 00000000..8f88c61f --- /dev/null +++ b/client/assets/trix.css @@ -0,0 +1,563 @@ +@charset "UTF-8"; + +/* +Trix 1.3.1 +Copyright © 2020 Basecamp, LLC +http://trix-editor.org/*/ +trix-editor { + border: 1px solid rgb(75, 85, 99); + border-radius: 3px; + background: rgb(35, 35, 35); + margin: 0; + padding: 0.4em 0.6em; + min-height: 5em; + outline: none; +} + +trix-toolbar * { + box-sizing: border-box; +} + +trix-toolbar .trix-button-row { + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + overflow-x: auto; +} + +trix-toolbar .trix-button-group { + display: flex; + margin-bottom: 10px; + border: 1px solid rgb(75, 85, 99); + border-top-color: rgb(75, 85, 99); + border-bottom-color: rgb(75, 85, 99); + border-radius: 3px; +} + +trix-toolbar .trix-button-group:not(:first-child) { + margin-left: 1.5vw; +} + +@media (max-device-width: 768px) { + trix-toolbar .trix-button-group:not(:first-child) { + margin-left: 0; + } +} + +trix-toolbar .trix-button-group-spacer { + flex-grow: 1; +} + +@media (max-device-width: 768px) { + trix-toolbar .trix-button-group-spacer { + display: none; + } +} + +trix-toolbar .trix-button { + position: relative; + float: left; + color: rgba(0, 0, 0, 0.6); + font-size: 0.75em; + font-weight: 600; + white-space: nowrap; + padding: 0 0.5em; + margin: 0; + outline: none; + border: none; + border-radius: 0; + background: transparent; +} + +trix-toolbar .trix-button:not(:first-child) { + border-left: 1px solid rgb(75, 85, 99); +} + +trix-toolbar .trix-button.trix-active { + background: #bbb; + color: black; +} + +trix-toolbar .trix-button:not(:disabled) { + cursor: pointer; + background: rgb(35, 35, 35); +} + +trix-toolbar .trix-button:disabled { + color: rgba(0, 0, 0, 0.25); +} + +@media (max-device-width: 768px) { + trix-toolbar .trix-button { + letter-spacing: -0.01em; + padding: 0 0.3em; + } +} + +trix-toolbar .trix-button--icon { + font-size: inherit; + width: 2.6em; + height: 1.6em; + max-width: calc(0.8em + 4vw); + text-indent: -9999px; +} + +@media (max-device-width: 768px) { + trix-toolbar .trix-button--icon { + height: 2em; + max-width: calc(0.8em + 3.5vw); + } +} + +trix-toolbar .trix-button--icon::before { + display: inline-block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.6; + content: ""; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: invert(100%); +} + +@media (max-device-width: 768px) { + trix-toolbar .trix-button--icon::before { + right: 6%; + left: 6%; + } +} + +trix-toolbar .trix-button--icon.trix-active::before { + opacity: 1; +} + +trix-toolbar .trix-button--icon:disabled::before { + opacity: 0.125; +} + +trix-toolbar .trix-button--icon-attach::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E); + top: 8%; + bottom: 4%; +} + +trix-toolbar .trix-button--icon-bold::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-italic::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-link::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-strike::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-quote::before { + background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-heading-1::before { + background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-code::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-bullet-list::before { + background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-number-list::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-undo::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-redo::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-decrease-nesting-level::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-button--icon-increase-nesting-level::before { + background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E); +} + +trix-toolbar .trix-dialogs { + position: relative; +} + +trix-toolbar .trix-dialog { + position: absolute; + top: 0; + left: 0; + right: 0; + font-size: 0.75em; + padding: 15px 10px; + background: rgb(48, 48, 48); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + border: 1px solid rgb(112, 112, 112); + border-radius: 5px; + z-index: 5; +} + +trix-toolbar .trix-input--dialog { + font-size: inherit; + font-weight: normal; + padding: 0.5em 0.8em; + margin: 0 10px 0 0; + border-radius: 3px; + border: 1px solid #bbb; + background-color: rgb(95, 95, 95); + box-shadow: none; + outline: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +trix-toolbar .trix-input--dialog.validate:invalid { + box-shadow: #F00 0px 0px 1.5px 1px; +} + +trix-toolbar .trix-button--dialog { + font-size: inherit; + padding: 0.5em; + border-bottom: none; + color: #eee; +} + +trix-toolbar .trix-dialog--link { + max-width: 600px; +} + +trix-toolbar .trix-dialog__link-fields { + display: flex; + align-items: baseline; +} + +trix-toolbar .trix-dialog__link-fields .trix-input { + flex: 1; +} + +trix-toolbar .trix-dialog__link-fields .trix-button-group { + flex: 0 0 content; + margin: 0; +} + +trix-editor [data-trix-mutable]:not(.attachment__caption-editor) { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +trix-editor [data-trix-mutable]::-moz-selection, +trix-editor [data-trix-cursor-target]::-moz-selection, +trix-editor [data-trix-mutable] ::-moz-selection { + background: none; +} + +trix-editor [data-trix-mutable]::selection, +trix-editor [data-trix-cursor-target]::selection, +trix-editor [data-trix-mutable] ::selection { + background: none; +} + +trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection { + background: highlight; +} + +trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection { + background: highlight; +} + +trix-editor [data-trix-mutable].attachment.attachment--file { + box-shadow: 0 0 0 2px highlight; + border-color: transparent; +} + +trix-editor [data-trix-mutable].attachment img { + box-shadow: 0 0 0 2px highlight; +} + +trix-editor .attachment { + position: relative; +} + +trix-editor .attachment:hover { + cursor: default; +} + +trix-editor .attachment--preview .attachment__caption:hover { + cursor: text; +} + +trix-editor .attachment__progress { + position: absolute; + z-index: 1; + height: 20px; + top: calc(50% - 10px); + left: 5%; + width: 90%; + opacity: 0.9; + transition: opacity 200ms ease-in; +} + +trix-editor .attachment__progress[value="100"] { + opacity: 0; +} + +trix-editor .attachment__caption-editor { + display: inline-block; + width: 100%; + margin: 0; + padding: 0; + font-size: inherit; + font-family: inherit; + line-height: inherit; + color: inherit; + text-align: center; + vertical-align: top; + border: none; + outline: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +trix-editor .attachment__toolbar { + position: absolute; + z-index: 1; + top: -0.9em; + left: 0; + width: 100%; + text-align: center; +} + +trix-editor .trix-button-group { + display: inline-flex; +} + +trix-editor .trix-button { + position: relative; + float: left; + color: #666; + white-space: nowrap; + font-size: 80%; + padding: 0 0.8em; + margin: 0; + outline: none; + border: none; + border-radius: 0; + background: transparent; +} + +trix-editor .trix-button:not(:first-child) { + border-left: 1px solid #ccc; +} + +trix-editor .trix-button.trix-active { + background: #cbeefa; +} + +trix-editor .trix-button:not(:disabled) { + cursor: pointer; +} + +trix-editor .trix-button--remove { + text-indent: -9999px; + display: inline-block; + padding: 0; + outline: none; + width: 1.8em; + height: 1.8em; + line-height: 1.8em; + border-radius: 50%; + background-color: #fff; + border: 2px solid highlight; + box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); +} + +trix-editor .trix-button--remove::before { + display: inline-block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.7; + content: ""; + background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E); + background-position: center; + background-repeat: no-repeat; + background-size: 90%; +} + +trix-editor .trix-button--remove:hover { + border-color: #333; +} + +trix-editor .trix-button--remove:hover::before { + opacity: 1; +} + +trix-editor .attachment__metadata-container { + position: relative; +} + +trix-editor .attachment__metadata { + position: absolute; + left: 50%; + top: 2em; + transform: translate(-50%, 0); + max-width: 90%; + padding: 0.1em 0.6em; + font-size: 0.8em; + color: #fff; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 3px; +} + +trix-editor .attachment__metadata .attachment__name { + display: inline-block; + max-width: 100%; + vertical-align: bottom; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +trix-editor .attachment__metadata .attachment__size { + margin-left: 0.2em; + white-space: nowrap; +} + +.trix-content { + line-height: 1.5; +} + +.trix-content * { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.trix-content h1 { + font-size: 1.2em; + line-height: 1.2; +} + +.trix-content blockquote { + border: 0 solid #ccc; + border-left-width: 0.3em; + margin-left: 0.3em; + padding-left: 0.6em; +} + +.trix-content [dir=rtl] blockquote, +.trix-content blockquote[dir=rtl] { + border-width: 0; + border-right-width: 0.3em; + margin-right: 0.3em; + padding-right: 0.6em; +} + +.trix-content li { + margin-left: 1em; +} + +.trix-content [dir=rtl] li { + margin-right: 1em; +} + +.trix-content pre { + display: inline-block; + width: 100%; + vertical-align: top; + font-family: monospace; + font-size: 0.9em; + padding: 0.5em; + white-space: pre; + background-color: #eee; + overflow-x: auto; +} + +.trix-content img { + max-width: 100%; + height: auto; +} + +.trix-content .attachment { + display: inline-block; + position: relative; + max-width: 100%; +} + +.trix-content .attachment a { + color: inherit; + text-decoration: none; +} + +.trix-content .attachment a:hover, +.trix-content .attachment a:visited:hover { + color: inherit; +} + +.trix-content .attachment__caption { + text-align: center; +} + +.trix-content .attachment__caption .attachment__name+.attachment__size::before { + content: ' · '; +} + +.trix-content .attachment--preview { + width: 100%; + text-align: center; +} + +.trix-content .attachment--preview .attachment__caption { + color: #666; + font-size: 0.9em; + line-height: 1.2; +} + +.trix-content .attachment--file { + color: #333; + line-height: 1; + margin: 0 2px 2px 2px; + padding: 0.4em 1em; + border: 1px solid #bbb; + border-radius: 5px; +} + +.trix-content .attachment-gallery { + display: flex; + flex-wrap: wrap; + position: relative; +} + +.trix-content .attachment-gallery .attachment { + flex: 1 0 33%; + padding: 0 0.5em; + max-width: 33%; +} + +.trix-content .attachment-gallery.attachment-gallery--2 .attachment, +.trix-content .attachment-gallery.attachment-gallery--4 .attachment { + flex-basis: 50%; + max-width: 50%; +} \ No newline at end of file diff --git a/client/components/modals/podcast/EditEpisode.vue b/client/components/modals/podcast/EditEpisode.vue index dbf76bf7..fe6ebcfd 100644 --- a/client/components/modals/podcast/EditEpisode.vue +++ b/client/components/modals/podcast/EditEpisode.vue @@ -5,7 +5,7 @@ <p class="font-book text-3xl text-white truncate">{{ title }}</p> </div> </template> - <div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden"> + <div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh"> <div class="flex flex-wrap"> <div class="w-1/5 p-1"> <ui-text-input-with-label v-model="newEpisode.season" label="Season" /> @@ -25,8 +25,8 @@ <div class="w-full p-1"> <ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" /> </div> - <div class="w-full p-1"> - <ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" /> + <div class="w-full p-1 default-style"> + <ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" /> </div> </div> <div class="flex justify-end pt-4"> diff --git a/client/components/ui/RichTextEditor.vue b/client/components/ui/RichTextEditor.vue new file mode 100644 index 00000000..068bc95f --- /dev/null +++ b/client/components/ui/RichTextEditor.vue @@ -0,0 +1,75 @@ +<template> + <div> + <p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"> + {{ label }} + </p> + <ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" /> + </div> +</template> + +<script> +export default { + props: { + value: String, + label: String, + disabled: Boolean + }, + data() { + return {} + }, + computed: { + content: { + get() { + return this.value + }, + set(val) { + this.$emit('input', val) + } + }, + config() { + return { + toolbar: { + getDefaultHTML: () => ` <div class="trix-button-row"> + <span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools"> + <button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button> + <button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button> + <button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button> + <button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button> + </span> + <span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools"> + <button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button> + <button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button> + </span> + + <span class="trix-button-group-spacer"></span> + <span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools"> + <button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button> + <button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button> + </span> + </div> + <div class="trix-dialogs" data-trix-dialogs> + <div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href"> + <div class="trix-dialog__link-fields"> + <input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input> + <div class="trix-button-group"> + <input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute"> + <input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute"> + </div> + </div> + </div> + </div>` + } + } + } + }, + methods: { + trixFileAccept(e) { + e.preventDefault() + } + }, + mounted() {}, + beforeDestroy() { + console.log('Before destroy') + } +} +</script> \ No newline at end of file diff --git a/client/components/ui/VueTrix.vue b/client/components/ui/VueTrix.vue new file mode 100644 index 00000000..e6f65733 --- /dev/null +++ b/client/components/ui/VueTrix.vue @@ -0,0 +1,284 @@ +<template> + <div> + <trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" /> + <input type="hidden" :name="inputName" :id="computedId" :value="editorContent" /> + </div> +</template> + +<script> +/* + ORIGINAL SOURCE: https://github.com/hanhdt/vue-trix + + modified for audiobookshelf +*/ +import Trix from 'trix' +import '@/assets/trix.css' + +export default { + name: 'vue-trix', + model: { + prop: 'srcContent', + event: 'update' + }, + props: { + /** + * This prop will put the editor in read-only mode + */ + disabledEditor: { + type: Boolean, + required: false, + default() { + return false + } + }, + /** + * This is referenced `id` of the hidden input field defined. + * It is optional and will be a random string by default. + */ + inputId: { + type: String, + required: false, + default() { + return '' + } + }, + /** + * This is referenced `name` of the hidden input field defined, + * default value is `content`. + */ + inputName: { + type: String, + required: false, + default() { + return 'content' + } + }, + /** + * The placeholder attribute specifies a short hint + * that describes the expected value of a editor. + */ + placeholder: { + type: String, + required: false, + default() { + return '' + } + }, + /** + * The source content is associcated to v-model directive. + */ + srcContent: { + type: String, + required: false, + default() { + return '' + } + }, + /** + * The boolean attribute allows saving editor state into browser's localStorage + * (optional, default is `false`). + */ + localStorage: { + type: Boolean, + required: false, + default() { + return false + } + }, + /** + * Focuses cursor in the editor when attached to the DOM + * (optional, default is `false`). + */ + autofocus: { + type: Boolean, + required: false, + default() { + return false + } + }, + /** + * Object to override default editor configuration + */ + config: { + type: Object, + required: false, + default() { + return {} + } + } + }, + data() { + return { + editorContent: this.srcContent, + isActived: null + } + }, + watch: { + editorContent: { + handler: 'emitEditorState' + }, + initialContent: { + handler: 'handleInitialContentChange' + }, + isDisabled: { + handler: 'decorateDisabledEditor' + }, + config: { + handler: 'overrideConfig', + immediate: true, + deep: true + } + }, + computed: { + /** + * Compute a random id of hidden input + * when it haven't been specified. + */ + generateId() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + var r = (Math.random() * 16) | 0 + var v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + }, + computedId() { + return this.inputId || this.generateId + }, + initialContent() { + return this.srcContent + }, + isDisabled() { + return this.disabledEditor + } + }, + methods: { + processTrixFocus(event) { + if (this.$refs.trix) { + this.isActived = true + this.$emit('trix-focus', this.$refs.trix.editor, event) + } + }, + processTrixBlur(event) { + if (this.$refs.trix) { + this.isActived = false + this.$emit('trix-blur', this.$refs.trix.editor, event) + } + }, + handleContentChange(event) { + this.editorContent = event.srcElement ? event.srcElement.value : event.target.value + this.$emit('input', this.editorContent) + }, + handleInitialize(event) { + /** + * If autofocus is true, manually set focus to + * beginning of content (consistent with Trix behavior) + */ + if (this.autofocus) { + this.$refs.trix.editor.setSelectedRange(0) + } + this.$emit('trix-initialize', this.emitInitialize) + }, + handleInitialContentChange(newContent, oldContent) { + newContent = newContent === undefined ? '' : newContent + if (this.$refs.trix.editor && this.$refs.trix.editor.innerHTML !== newContent) { + /* Update editor's content when initial content changed */ + this.editorContent = newContent + /** + * If user are typing, then don't reload the editor, + * hence keep cursor's position after typing. + */ + if (!this.isActived) { + this.reloadEditorContent(this.editorContent) + } + } + }, + emitEditorState(value) { + /** + * If localStorage is enabled, + * then save editor's content into storage + */ + if (this.localStorage) { + localStorage.setItem(this.storageId('VueTrix'), JSON.stringify(this.$refs.trix.editor)) + } + this.$emit('update', this.editorContent) + }, + storageId(component) { + if (this.inputId) { + return `${component}.${this.inputId}.content` + } else { + return `${component}.content` + } + }, + reloadEditorContent(newContent) { + // Reload HTML content + this.$refs.trix.editor.loadHTML(newContent) + // Move cursor to end of new content updated + this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition()) + }, + getContentEndPosition() { + return this.$refs.trix.editor.getDocument().toString().length - 1 + }, + decorateDisabledEditor(editorState) { + /** Disable toolbar and editor by pointer events styling */ + if (editorState) { + this.$refs.trix.toolbarElement.style['pointer-events'] = 'none' + this.$refs.trix.contentEditable = false + this.$refs.trix.style['background'] = '#e9ecef' + } else { + this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset' + this.$refs.trix.style['pointer-events'] = 'unset' + this.$refs.trix.style['background'] = 'transparent' + } + }, + overrideConfig(config) { + Trix.config = this.deepMerge(Trix.config, config) + }, + deepMerge(target, override) { + // deep merge the object into the target object + for (let prop in override) { + if (override.hasOwnProperty(prop)) { + if (Object.prototype.toString.call(override[prop]) === '[object Object]') { + // if the property is a nested object + target[prop] = this.deepMerge(target[prop], override[prop]) + } else { + // for regular property + target[prop] = override[prop] + } + } + } + return target + } + }, + mounted() { + /** Override editor configuration */ + this.overrideConfig(this.config) + /** Check if editor read-only mode is required */ + this.decorateDisabledEditor(this.disabledEditor) + this.$nextTick(() => { + /** + * If localStorage is enabled, + * then load editor's content from the beginning. + */ + if (this.localStorage) { + const savedValue = localStorage.getItem(this.storageId('VueTrix')) + if (savedValue && !this.srcContent) { + this.$refs.trix.editor.loadJSON(JSON.parse(savedValue)) + } + } + }) + } +} +</script> + +<style lang="css" module> +.trix_container { + max-width: 100%; + height: auto; +} +.trix_container .trix-button-group { + background-color: white; +} +.trix_container .trix-content { + background-color: white; +} +</style> \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index a85ac544..e5de4940 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.0.14", + "version": "2.0.17", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.0.14", + "version": "2.0.17", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", @@ -18,6 +18,7 @@ "libarchive.js": "^1.3.0", "nuxt": "^2.15.8", "nuxt-socket-io": "^1.1.18", + "trix": "^1.3.1", "v-click-outside": "^3.1.2", "vue-pdf": "^4.3.0", "vue-toastification": "^1.7.11", @@ -15285,6 +15286,11 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, + "node_modules/trix": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz", + "integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA==" + }, "node_modules/ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", @@ -29080,6 +29086,11 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, + "trix": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz", + "integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA==" + }, "ts-pnp": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", diff --git a/client/package.json b/client/package.json index 876ef805..b1eee263 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,7 @@ "libarchive.js": "^1.3.0", "nuxt": "^2.15.8", "nuxt-socket-io": "^1.1.18", + "trix": "^1.3.1", "v-click-outside": "^3.1.2", "vue-pdf": "^4.3.0", "vue-toastification": "^1.7.11", @@ -32,4 +33,4 @@ "@nuxtjs/tailwindcss": "^4.2.1", "postcss": "^8.3.6" } -} \ No newline at end of file +} diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js index cc046de8..bfd0a619 100644 --- a/server/utils/htmlSanitizer.js +++ b/server/utils/htmlSanitizer.js @@ -3,7 +3,7 @@ const sanitizeHtml = require('../libs/sanitizeHtml') function sanitize(html) { const sanitizerOptions = { allowedTags: [ - 'p', 'ol', 'ul', 'li', 'a', 'strong', 'em' + 'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del' ], disallowedTagsMode: 'discard', allowedAttributes: {