From 656c81a1fa6a0d599df7fac77b5cfbe7431d6b62 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 13 Oct 2023 17:37:37 -0500 Subject: [PATCH] Update:Remove image path input from author modal, add API endpoints for uploading and removing author image --- .../components/modals/authors/EditModal.vue | 92 ++++++++++------ server/controllers/AuthorController.js | 103 +++++++++++++----- server/finders/AuthorFinder.js | 45 ++++---- server/routers/ApiRouter.js | 2 + 4 files changed, 162 insertions(+), 80 deletions(-) diff --git a/client/components/modals/authors/EditModal.vue b/client/components/modals/authors/EditModal.vue index 3af64249..a4fb48a2 100644 --- a/client/components/modals/authors/EditModal.vue +++ b/client/components/modals/authors/EditModal.vue @@ -5,18 +5,23 @@

{{ title }}

-
-
-
-
-
- -
- delete -
+
+
+
+
+ +
+ delete
-
+
+
+ + + {{ $strings.ButtonSubmit }} + + +
@@ -25,9 +30,9 @@
-
+
@@ -39,9 +44,9 @@ {{ $strings.ButtonSave }}
-
+
- +
@@ -53,9 +58,9 @@ export default { authorCopy: { name: '', asin: '', - description: '', - imagePath: '' + description: '' }, + imageUrl: '', processing: false } }, @@ -100,10 +105,10 @@ export default { }, methods: { init() { + this.imageUrl = '' this.authorCopy.name = this.author.name this.authorCopy.asin = this.author.asin this.authorCopy.description = this.author.description - this.authorCopy.imagePath = this.author.imagePath }, removeClick() { const payload = { @@ -131,7 +136,7 @@ export default { this.$store.commit('globals/setConfirmPrompt', payload) }, async submitForm() { - var keysToCheck = ['name', 'asin', 'description', 'imagePath'] + var keysToCheck = ['name', 'asin', 'description'] var updatePayload = {} keysToCheck.forEach((key) => { if (this.authorCopy[key] !== this.author[key]) { @@ -160,21 +165,46 @@ export default { } this.processing = false }, - async removeCover() { - var updatePayload = { - imagePath: null - } + removeCover() { this.processing = true - var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { - console.error('Failed', error) - this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) - return null - }) - if (result && result.updated) { - this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) - this.$store.commit('globals/showEditAuthorModal', result.author) + this.$axios + .$delete(`/api/authors/${this.authorId}/image`) + .then((data) => { + this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) + this.$store.commit('globals/showEditAuthorModal', data.author) + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) + }) + .finally(() => { + this.processing = false + }) + }, + submitUploadCover() { + if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) { + this.$toast.error('Invalid image url') + return } - this.processing = false + + this.processing = true + const updatePayload = { + url: this.imageUrl + } + this.$axios + .$post(`/api/authors/${this.authorId}/image`, updatePayload) + .then((data) => { + this.imageUrl = '' + this.$toast.success('Author image updated') + this.$store.commit('globals/showEditAuthorModal', data.author) + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error(error.response.data || 'Failed to remove author image') + }) + .finally(() => { + this.processing = false + }) }, async searchAuthor() { if (!this.authorCopy.name && !this.authorCopy.asin) { diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 0cd243fd..62a7ebde 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -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)) } diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js index 9c2a3b4f..59c6ce16 100644 --- a/server/finders/AuthorFinder.js +++ b/server/finders/AuthorFinder.js @@ -10,13 +10,6 @@ class AuthorFinder { this.audnexus = new Audnexus() } - async downloadImage(url, outputPath) { - return downloadFile(url, outputPath).then(() => true).catch((error) => { - Logger.error('[AuthorFinder] Failed to download author image', error) - return null - }) - } - findAuthorByASIN(asin, region) { if (!asin) return null return this.audnexus.findAuthorByASIN(asin, region) @@ -33,28 +26,36 @@ class AuthorFinder { return author } + /** + * Download author image from url and save in authors folder + * + * @param {string} authorId + * @param {string} url + * @returns {Promise<{path:string, error:string}>} + */ async saveAuthorImage(authorId, url) { - var authorDir = Path.join(global.MetadataPath, 'authors') - var relAuthorDir = Path.posix.join('/metadata', 'authors') + const authorDir = Path.join(global.MetadataPath, 'authors') if (!await fs.pathExists(authorDir)) { await fs.ensureDir(authorDir) } - var imageExtension = url.toLowerCase().split('.').pop() - var ext = imageExtension === 'png' ? 'png' : 'jpg' - var filename = authorId + '.' + ext - var outputPath = Path.posix.join(authorDir, filename) - var relPath = Path.posix.join(relAuthorDir, filename) + const imageExtension = url.toLowerCase().split('.').pop() + const ext = imageExtension === 'png' ? 'png' : 'jpg' + const filename = authorId + '.' + ext + const outputPath = Path.posix.join(authorDir, filename) - var success = await this.downloadImage(url, outputPath) - if (!success) { - return null - } - return { - path: outputPath, - relPath - } + return downloadFile(url, outputPath).then(() => { + return { + path: outputPath + } + }).catch((err) => { + let errorMsg = err.message || 'Unknown error' + Logger.error(`[AuthorFinder] Download image file failed for "${url}"`, errorMsg) + return { + error: errorMsg + } + }) } } module.exports = new AuthorFinder() \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index dc816b44..a90d1873 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -202,6 +202,8 @@ class ApiRouter { this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) + this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) + this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) // // Series Routes