Merge master

This commit is contained in:
advplyr 2023-11-01 08:58:48 -05:00
commit ab14b561f5
147 changed files with 4669 additions and 5036 deletions

View file

@ -67,30 +67,10 @@ class AuthorController {
const payload = req.body
let hasUpdated = false
// Updating/removing cover image
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
hasUpdated = true
}
} else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally
if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists
Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`)
return res.status(400).send('Author image path does not exist')
}
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
}
// author imagePath must be set through other endpoints as of v2.4.5
if (payload.imagePath !== undefined) {
Logger.warn(`[AuthorController] Updating local author imagePath is not supported`)
delete payload.imagePath
}
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
@ -131,7 +111,7 @@ class AuthorController {
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)
// Send updated num books for merged author
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
const numBooks = (await Database.libraryItemModel.getForAuthor(existingAuthor)).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
@ -191,6 +171,75 @@ class AuthorController {
res.sendStatus(200)
}
/**
* POST: /api/authors/:id/image
* Upload author image from web URL
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async uploadImage(req, res) {
if (!req.user.canUpload) {
Logger.warn('User attempted to upload an image without permission', req.user)
return res.sendStatus(403)
}
if (!req.body.url) {
Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`)
return res.status(400).send(`Invalid request payload. 'url' not in request body`)
}
if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) {
Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`)
return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`)
}
Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`)
const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)
if (result?.error) {
return res.status(400).send(result.error)
} else if (!result?.path) {
return res.status(500).send('Unknown error occurred')
}
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
req.author.imagePath = result.path
await Database.authorModel.updateFromOld(req.author)
const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
res.json({
author: req.author.toJSON()
})
}
/**
* DELETE: /api/authors/:id/image
* Remove author image & delete image file
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async deleteImage(req, res) {
if (!req.author.imagePath) {
Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`)
return res.status(400).send('Author has no image path set')
}
Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`)
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
req.author.imagePath = null
await Database.authorModel.updateFromOld(req.author)
const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
res.json({
author: req.author.toJSON()
})
}
async match(req, res) {
let authorData = null
const region = req.body.region || 'us'
@ -215,7 +264,7 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id)
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) {
if (imageData?.path) {
req.author.imagePath = imageData.path
hasUpdates = true
}
@ -231,7 +280,7 @@ class AuthorController {
await Database.updateAuthor(req.author)
const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}

View file

@ -51,32 +51,45 @@ class EmailController {
})
}
/**
* Send ebook to device
* User must have access to device and library item
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async sendEBookToDevice(req, res) {
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
Logger.debug(`[EmailController] Send ebook to device requested by user "${req.user.username}" for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)
if (!device) {
return res.status(404).send('Ereader device not found')
}
// Check user has access to device
if (!Database.emailSettings.checkUserCanAccessDevice(device, req.user)) {
return res.sendStatus(403)
}
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Library item not found')
}
// Check user has access to library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
const ebookFile = libraryItem.media.ebookFile
if (!ebookFile) {
return res.status(404).send('EBook file not found')
}
const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)
if (!device) {
return res.status(404).send('E-reader device not found')
return res.status(404).send('Ebook file not found')
}
this.emailManager.sendEBookToDevice(ebookFile, device, res)
}
middleware(req, res, next) {
adminMiddleware(req, res, next) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(404)
}

View file

@ -9,7 +9,8 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const seriesFilters = require('../utils/queries/seriesFilters')
const fileUtils = require('../utils/fileUtils')
const { sort, createNewSortInstance } = require('../libs/fastSort')
const { asciiOnlyToLowerCase } = require('../utils/index')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
@ -555,7 +556,7 @@ class LibraryController {
return res.status(400).send('No query string')
}
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
const query = req.query.q.trim().toLowerCase()
const query = asciiOnlyToLowerCase(req.query.q.trim())
const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
res.json(matches)
@ -620,7 +621,7 @@ class LibraryController {
model: Database.bookModel,
attributes: ['id', 'tags', 'explicit'],
where: bookWhere,
required: false,
required: !req.user.isAdminOrUp, // Only show authors with 0 books for admin users or up
through: {
attributes: []
}
@ -774,6 +775,13 @@ class LibraryController {
})
}
/**
* GET: /api/libraries/:id/matchall
* Quick match all library items. Book libraries only.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async matchAll(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
@ -783,7 +791,14 @@ class LibraryController {
res.sendStatus(200)
}
// POST: api/libraries/:id/scan
/**
* POST: /api/libraries/:id/scan
* Optional query:
* ?force=1
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async scan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
@ -791,7 +806,8 @@ class LibraryController {
}
res.sendStatus(200)
await LibraryScanner.scan(req.library)
const forceRescan = req.query.force === '1'
await LibraryScanner.scan(req.library, forceRescan)
await Database.resetLibraryIssuesFilterData(req.library.id)
Logger.info('[LibraryController] Scan complete')
@ -845,6 +861,56 @@ class LibraryController {
res.send(opmlText)
}
/**
* Remove all metadata.json or metadata.abs files in library item folders
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async removeAllMetadataFiles(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-admin user attempted to remove all metadata files`, req.user)
return res.sendStatus(403)
}
const fileExt = req.query.ext === 'abs' ? 'abs' : 'json'
const metadataFilename = `metadata.${fileExt}`
const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({
attributes: ['id', 'libraryFiles'],
where: [
{
libraryId: req.library.id
},
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(libraryFiles) AND json_extract(json_each.value, "$.metadata.filename") = "${metadataFilename}")`), {
[Sequelize.Op.gte]: 1
})
]
})
if (!libraryItemsWithMetadata.length) {
Logger.info(`[LibraryController] No ${metadataFilename} files found to remove`)
return res.json({
found: 0
})
}
Logger.info(`[LibraryController] Found ${libraryItemsWithMetadata.length} ${metadataFilename} files to remove`)
let numRemoved = 0
for (const libraryItem of libraryItemsWithMetadata) {
const metadataFilepath = libraryItem.libraryFiles.find(lf => lf.metadata.filename === metadataFilename)?.metadata.path
if (!metadataFilepath) continue
Logger.debug(`[LibraryController] Removing file "${metadataFilepath}"`)
if ((await fileUtils.removeFile(metadataFilepath))) {
numRemoved++
}
}
res.json({
found: libraryItemsWithMetadata.length,
removed: numRemoved
})
}
/**
* Middleware that is not using libraryItems from memory
* @param {import('express').Request} req

View file

@ -85,12 +85,31 @@ class LibraryItemController {
res.sendStatus(200)
}
/**
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
download(req, res) {
if (!req.user.canDownload) {
Logger.warn('User attempted to download without permission', req.user)
return res.sendStatus(403)
}
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(req.libraryItem.path))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.download(req.libraryItem.path, req.libraryItem.relPath)
return
}
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
@ -163,22 +182,22 @@ class LibraryItemController {
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
let libraryItem = req.libraryItem
var result = null
if (req.body && req.body.url) {
let result = null
if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url)
} else if (req.files && req.files.cover) {
} else if (req.files?.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await CoverManager.uploadCover(libraryItem, req.files.cover)
} else {
return res.status(400).send('Invalid request no file or url')
}
if (result && result.error) {
if (result?.error) {
return res.status(400).send(result.error)
} else if (!result || !result.cover) {
} else if (!result?.cover) {
return res.status(500).send('Unknown error occurred')
}
@ -259,7 +278,6 @@ class LibraryItemController {
// Check if library item media has a cover path
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
Logger.debug(`[LibraryItemController] getCover: Library item "${req.params.id}" has no cover path`)
return res.sendStatus(404)
}

View file

@ -9,6 +9,8 @@ const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const TaskManager = require('../managers/TaskManager')
//
// This is a controller for routes that don't have a home yet :(
//
@ -102,7 +104,7 @@ class MiscController {
const includeArray = (req.query.include || '').split(',')
const data = {
tasks: this.taskManager.tasks.map(t => t.toJSON())
tasks: TaskManager.tasks.map(t => t.toJSON())
}
if (includeArray.includes('queue')) {
@ -526,6 +528,54 @@ class MiscController {
})
}
/**
* POST: /api/watcher/update
* Update a watch path
* Req.body { libraryId, path, type, [oldPath] }
* type = add, unlink, rename
* oldPath = required only for rename
* @this import('../routers/ApiRouter')
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
updateWatchedPath(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`)
return res.sendStatus(404)
}
const libraryId = req.body.libraryId
const path = req.body.path
const type = req.body.type
if (!libraryId || !path || !type) {
Logger.error(`[MiscController] Invalid request body for updateWatchedPath. libraryId: "${libraryId}", path: "${path}", type: "${type}"`)
return res.sendStatus(400)
}
switch (type) {
case 'add':
this.watcher.onFileAdded(libraryId, path)
break;
case 'unlink':
this.watcher.onFileRemoved(libraryId, path)
break;
case 'rename':
const oldPath = req.body.oldPath
if (!oldPath) {
Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`)
return res.sendStatus(400)
}
this.watcher.onFileRename(libraryId, oldPath, path)
break;
default:
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
return res.sendStatus(400)
}
res.sendStatus(200)
}
validateCronExpression(req, res) {
const expression = req.body.expression
if (!expression) {

View file

@ -184,10 +184,9 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
return res.sendStatus(403)
}
var libraryItem = req.libraryItem
var episodes = req.body
if (!episodes || !episodes.length) {
const libraryItem = req.libraryItem
const episodes = req.body
if (!episodes?.length) {
return res.sendStatus(400)
}
@ -286,7 +285,7 @@ class PodcastController {
const numItems = pmi.playlist.playlistMediaItems.length - 1
if (!numItems) {
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
await pmi.playlist.destroy()

View file

@ -26,7 +26,7 @@ class SearchController {
let results = null
if (podcast) results = await PodcastFinder.findCovers(query.title)
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || null)
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '')
res.json({
results
})

View file

@ -115,6 +115,13 @@ class UserController {
}
}
/**
* PATCH: /api/users/:id
* Update user
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async update(req, res) {
const user = req.reqUser
@ -126,6 +133,7 @@ class UserController {
var account = req.body
var shouldUpdateToken = false
// When changing username create a new API token
if (account.username !== undefined && account.username !== user.username) {
const usernameExists = await Database.userModel.getUserByUsername(account.username)
if (usernameExists) {