mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 18:31:43 +00:00
Add local avatar upload feature for author page
This commit is contained in:
parent
03da194953
commit
4c80072f23
3 changed files with 208 additions and 87 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden flex items-center justify-center">
|
||||||
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img v-if="imgSrc" :src="imgSrc" :alt="altText" class="w-full h-full object-cover" @load="imageLoaded" />
|
||||||
|
<svg v-else width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||||
<path
|
<path
|
||||||
|
|
@ -13,10 +14,6 @@
|
||||||
/>
|
/>
|
||||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||||
</svg>
|
</svg>
|
||||||
<div v-else class="w-full h-full relative">
|
|
||||||
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
|
||||||
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -25,51 +22,71 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
author: {
|
author: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => ({})
|
||||||
},
|
},
|
||||||
rounded: {
|
rounded: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'lg'
|
default: 'lg'
|
||||||
|
},
|
||||||
|
uploadedFile: {
|
||||||
|
type: [File, null],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
uploadedUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
altText: {
|
||||||
|
type: String,
|
||||||
|
default: 'author image'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showCoverBg: false,
|
objectUrl: null
|
||||||
coverContain: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
_author() {
|
|
||||||
return this.author || {}
|
|
||||||
},
|
|
||||||
authorId() {
|
|
||||||
return this._author.id
|
|
||||||
},
|
|
||||||
imagePath() {
|
imagePath() {
|
||||||
return this._author.imagePath
|
return this.author && this.author.imagePath
|
||||||
},
|
},
|
||||||
updatedAt() {
|
updatedAt() {
|
||||||
return this._author.updatedAt
|
return this.author && this.author.updatedAt
|
||||||
},
|
},
|
||||||
imgSrc() {
|
imgSrc() {
|
||||||
if (!this.imagePath) return null
|
// Prefer local file, then uploaded URL, then server image
|
||||||
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
|
if (this.objectUrl) return this.objectUrl
|
||||||
|
if (this.uploadedUrl) return this.uploadedUrl
|
||||||
|
if (this.imagePath) {
|
||||||
|
return `${this.$config.routerBasePath}/api/authors/${this.author.id}/image?ts=${this.updatedAt}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
watch: {
|
||||||
imageLoaded() {
|
uploadedFile: {
|
||||||
if (this.$refs.img) {
|
immediate: true,
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.img
|
handler(file) {
|
||||||
var imgAr = naturalHeight / naturalWidth
|
if (this.objectUrl) {
|
||||||
if (imgAr < 0.5 || imgAr > 2) {
|
URL.revokeObjectURL(this.objectUrl)
|
||||||
this.showCoverBg = true
|
this.objectUrl = null
|
||||||
} else {
|
}
|
||||||
this.showCoverBg = false
|
if (file && file instanceof File) {
|
||||||
this.coverContain = false
|
this.objectUrl = URL.createObjectURL(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
methods: {
|
||||||
|
imageLoaded() {
|
||||||
|
// Keep original functionality
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.objectUrl) {
|
||||||
|
URL.revokeObjectURL(this.objectUrl)
|
||||||
|
this.objectUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,21 @@
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-40 p-2">
|
<div class="w-40 p-2">
|
||||||
<div class="w-full h-45 relative">
|
<div class="w-full h-45 relative">
|
||||||
<covers-author-image :author="authorCopy" />
|
<covers-author-image :author="authorCopy" :uploaded-file="selectedFile" :uploaded-url="previewUrl" />
|
||||||
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
<div v-if="userCanDelete && !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-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
<span class="absolute top-2 right-2 material-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<form @submit.prevent="submitUploadCover" class="flex grow mb-2 p-2">
|
<form @submit.prevent="submitUploadCover" class="flex grow mb-2 p-2 items-center">
|
||||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" @input="onUrlInput" />
|
||||||
<ui-btn color="bg-success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
<label class="ml-2 sm:ml-3 w-24 h-9 flex items-center justify-center bg-gray-700 text-white rounded cursor-pointer hover:bg-gray-600">
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||||
|
Local
|
||||||
|
</label>
|
||||||
|
<ui-btn color="bg-success" type="submit" :padding-x="4" :disabled="!imageUrl && !selectedFile" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
<ui-btn v-if="selectedFile || previewUrl" type="button" color="bg-error" class="ml-2 sm:ml-3 w-24 h-9" small @click="clearSelection">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form v-if="author" @submit.prevent="submitForm">
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
|
|
@ -38,7 +43,6 @@
|
||||||
<ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
<ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||||
<div class="grow" />
|
<div class="grow" />
|
||||||
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
|
|
||||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -58,6 +62,8 @@ export default {
|
||||||
description: ''
|
description: ''
|
||||||
},
|
},
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
selectedFile: null,
|
||||||
|
previewUrl: '',
|
||||||
processing: false
|
processing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -103,10 +109,36 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
|
this.selectedFile = null
|
||||||
|
this.previewUrl = ''
|
||||||
this.authorCopy = {
|
this.authorCopy = {
|
||||||
...this.author
|
...this.author
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onFileChange(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
this.selectedFile = file
|
||||||
|
if (this.previewUrl) {
|
||||||
|
URL.revokeObjectURL(this.previewUrl)
|
||||||
|
}
|
||||||
|
this.previewUrl = URL.createObjectURL(file)
|
||||||
|
this.imageUrl = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUrlInput() {
|
||||||
|
if (this.selectedFile) {
|
||||||
|
this.clearSelection()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
this.imageUrl = ''
|
||||||
|
if (this.previewUrl) {
|
||||||
|
URL.revokeObjectURL(this.previewUrl)
|
||||||
|
this.previewUrl = ''
|
||||||
|
}
|
||||||
|
this.selectedFile = null
|
||||||
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
|
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
|
||||||
|
|
@ -168,7 +200,6 @@ export default {
|
||||||
.$delete(`/api/authors/${this.authorId}/image`)
|
.$delete(`/api/authors/${this.authorId}/image`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||||
|
|
||||||
this.authorCopy.updatedAt = data.author.updatedAt
|
this.authorCopy.updatedAt = data.author.updatedAt
|
||||||
this.authorCopy.imagePath = data.author.imagePath
|
this.authorCopy.imagePath = data.author.imagePath
|
||||||
})
|
})
|
||||||
|
|
@ -181,6 +212,28 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submitUploadCover() {
|
submitUploadCover() {
|
||||||
|
if (this.selectedFile) {
|
||||||
|
this.processing = true
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', this.selectedFile)
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/authors/${this.authorId}/image`, formData)
|
||||||
|
.then((data) => {
|
||||||
|
this.clearSelection()
|
||||||
|
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||||
|
this.authorCopy.updatedAt = data.author.updatedAt
|
||||||
|
this.authorCopy.imagePath = data.author.imagePath
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error(error.response?.data || this.$strings.ToastRemoveFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
||||||
this.$toast.error(this.$strings.ToastInvalidImageUrl)
|
this.$toast.error(this.$strings.ToastInvalidImageUrl)
|
||||||
return
|
return
|
||||||
|
|
@ -193,15 +246,14 @@ export default {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.imageUrl = ''
|
this.clearSelection()
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||||
|
|
||||||
this.authorCopy.updatedAt = data.author.updatedAt
|
this.authorCopy.updatedAt = data.author.updatedAt
|
||||||
this.authorCopy.imagePath = data.author.imagePath
|
this.authorCopy.imagePath = data.author.imagePath
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error(error.response.data || this.$strings.ToastRemoveFailed)
|
this.$toast.error(error.response?.data || this.$strings.ToastRemoveFailed)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
@ -245,7 +297,10 @@ export default {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
beforeDestroy() {
|
||||||
beforeDestroy() {}
|
if (this.previewUrl) {
|
||||||
|
URL.revokeObjectURL(this.previewUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ const { Request, Response, NextFunction } = require('express')
|
||||||
const sequelize = require('sequelize')
|
const sequelize = require('sequelize')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const { createNewSortInstance } = require('../libs/fastSort')
|
const { createNewSortInstance } = require('../libs/fastSort')
|
||||||
|
const axios = require('axios')
|
||||||
|
const Path = require('path')
|
||||||
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
|
@ -258,7 +260,7 @@ class AuthorController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST: /api/authors/:id/image
|
* POST: /api/authors/:id/image
|
||||||
* Upload author image from web URL
|
* Upload author image from file (express-fileupload) or URL
|
||||||
*
|
*
|
||||||
* @param {AuthorControllerRequest} req
|
* @param {AuthorControllerRequest} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
|
|
@ -268,30 +270,42 @@ class AuthorController {
|
||||||
Logger.warn(`User "${req.user.username}" attempted to upload an image without permission`)
|
Logger.warn(`User "${req.user.username}" attempted to upload an image without permission`)
|
||||||
return res.sendStatus(403)
|
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}"`)
|
let imagePath = null
|
||||||
const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)
|
|
||||||
|
|
||||||
if (result?.error) {
|
// 1. File upload (express-fileupload)
|
||||||
return res.status(400).send(result.error)
|
if (req.files && req.files.image) {
|
||||||
} else if (!result?.path) {
|
const uploadedFile = req.files.image
|
||||||
return res.status(500).send('Unknown error occurred')
|
const uploadsDir = Path.join(global.MetadataPath, 'uploads')
|
||||||
|
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9)
|
||||||
|
const fileName = 'image-' + uniqueSuffix + Path.extname(uploadedFile.name)
|
||||||
|
const filePath = Path.join(uploadsDir, fileName)
|
||||||
|
await uploadedFile.mv(filePath)
|
||||||
|
imagePath = filePath
|
||||||
|
}
|
||||||
|
// 2. URL upload
|
||||||
|
else if (req.body.url && (req.body.url.startsWith('http://') || req.body.url.startsWith('https://'))) {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
imagePath = result.path
|
||||||
|
} else {
|
||||||
|
Logger.error(`[AuthorController] Invalid request payload. No file or valid url`)
|
||||||
|
return res.status(400).send(`Invalid request payload. No file or valid url`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.author.imagePath) {
|
if (req.author.imagePath) {
|
||||||
await CacheManager.purgeImageCache(req.author.id) // Purge cache
|
await CacheManager.purgeImageCache(req.author.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.author.imagePath = result.path
|
req.author.imagePath = imagePath
|
||||||
// imagePath may not have changed, but we still want to update the updatedAt field to bust image cache
|
|
||||||
req.author.changed('imagePath', true)
|
req.author.changed('imagePath', true)
|
||||||
await req.author.save()
|
await req.author.save()
|
||||||
|
|
||||||
|
|
@ -311,20 +325,40 @@ class AuthorController {
|
||||||
*/
|
*/
|
||||||
async deleteImage(req, res) {
|
async deleteImage(req, res) {
|
||||||
if (!req.author.imagePath) {
|
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')
|
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}"`)
|
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)
|
// Clear cache
|
||||||
|
try {
|
||||||
|
await CacheManager.purgeImageCache(req.author.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Cache purge error (ignored):', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only local files are deleted
|
||||||
|
if (!req.author.imagePath.startsWith('http')) {
|
||||||
|
await CoverManager.removeFile(req.author.imagePath)
|
||||||
|
}
|
||||||
|
|
||||||
req.author.imagePath = null
|
req.author.imagePath = null
|
||||||
|
// Mark changes and explicitly update updatedAt to ensure the client receives the new timestamp
|
||||||
|
req.author.updatedAt = new Date()
|
||||||
|
req.author.changed('imagePath', true)
|
||||||
await req.author.save()
|
await req.author.save()
|
||||||
|
|
||||||
|
// Clear cache again
|
||||||
|
try {
|
||||||
|
await CacheManager.purgeImageCache(req.author.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Cache purge error (ignored):', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
|
||||||
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
|
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
|
||||||
res.json({
|
|
||||||
author: req.author.toOldJSON()
|
res.json({ author: req.author.toOldJSON() })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -388,37 +422,52 @@ class AuthorController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getImage(req, res) {
|
async getImage(req, res) {
|
||||||
const {
|
const { raw } = req.query
|
||||||
query: { width, height, format, raw }
|
const author = await Database.authorModel.findByPk(req.params.id)
|
||||||
} = req
|
if (!author || !author.imagePath) return res.sendStatus(404)
|
||||||
|
|
||||||
const authorId = req.params.id
|
// Set no-cache headers to always get the latest image
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
Expires: '0'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Proxy external image URLs
|
||||||
|
if (author.imagePath.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(author.imagePath, { responseType: 'stream' })
|
||||||
|
res.setHeader('Content-Type', response.headers['content-type'] || 'image/jpeg')
|
||||||
|
response.data.pipe(res)
|
||||||
|
} catch (err) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local file
|
||||||
|
let imagePath = author.imagePath
|
||||||
|
if (!Path.isAbsolute(imagePath)) {
|
||||||
|
imagePath = Path.resolve(process.cwd(), imagePath)
|
||||||
|
}
|
||||||
|
if (!(await fs.pathExists(imagePath))) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const author = await Database.authorModel.findByPk(authorId)
|
return res.sendFile(imagePath)
|
||||||
if (!author) {
|
|
||||||
Logger.warn(`[AuthorController] Author "${authorId}" not found`)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
|
|
||||||
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.sendFile(author.imagePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
format: 'jpeg',
|
||||||
height: height ? parseInt(height) : null,
|
width: req.query.width ? parseInt(req.query.width) : null,
|
||||||
width: width ? parseInt(width) : null
|
height: req.query.height ? parseInt(req.query.height) : null
|
||||||
}
|
}
|
||||||
return CacheManager.handleAuthorCache(res, authorId, options)
|
return CacheManager.handleAuthorCache(res, author.id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Middleware for author routes
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
|
|
@ -426,6 +475,7 @@ class AuthorController {
|
||||||
async middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const author = await Database.authorModel.findByPk(req.params.id)
|
const author = await Database.authorModel.findByPk(req.params.id)
|
||||||
if (!author) return res.sendStatus(404)
|
if (!author) return res.sendStatus(404)
|
||||||
|
req.author = author
|
||||||
|
|
||||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
|
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
|
||||||
|
|
@ -435,7 +485,6 @@ class AuthorController {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.author = author
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue