Update:Remove local cover path input & replace with url from web input, include SSRF request filter

This commit is contained in:
advplyr 2023-10-13 16:33:47 -05:00
parent 05731c9f72
commit 290a377ef9
20 changed files with 117 additions and 66 deletions

View File

@ -7,7 +7,7 @@
<!-- book cover overlay --> <!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> <div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" /> <div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover"> <div v-if="userCanDelete" class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover"> <ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
<span class="material-icons text-2xl">delete</span> <span class="material-icons text-2xl">delete</span>
</ui-tooltip> </ui-tooltip>
@ -16,15 +16,16 @@
</div> </div>
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0"> <div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center"> <div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32"> <div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected"> <ui-file-input ref="fileInput" @change="fileUploadSelected">
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span> <span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
<span class="material-icons text-2xl inline-block md:!hidden">upload</span> <span class="material-icons text-2xl inline-block md:!hidden">upload</span>
</ui-file-input> </ui-file-input>
</div> </div>
<form @submit.prevent="submitForm" class="flex flex-grow"> <form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" /> <ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">{{ $strings.ButtonSave }}</ui-btn> <ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
</form> </form>
</div> </div>
@ -64,7 +65,7 @@
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full"> <div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p> <p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
<template v-for="cover in coversFound"> <template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)"> <div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
</template> </template>
@ -165,6 +166,9 @@ export default {
userCanUpload() { userCanUpload() {
return this.$store.getters['user/getUserCanUpload'] return this.$store.getters['user/getUserCanUpload']
}, },
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
@ -222,71 +226,53 @@ export default {
this.coversFound = [] this.coversFound = []
this.hasSearched = false this.hasSearched = false
} }
this.imageUrl = this.media.coverPath || '' this.imageUrl = ''
this.searchTitle = this.mediaMetadata.title || '' this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || '' this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes' if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google' else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
}, },
removeCover() { removeCover() {
if (!this.media.coverPath) { if (!this.coverPath) {
this.imageUrl = ''
return return
} }
this.updateCover('') this.isProcessing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/cover`)
.then(() => {})
.catch((error) => {
console.error('Failed to remove cover', error)
if (error.response?.data) {
this.$toast.error(error.response.data)
}
})
.finally(() => {
this.isProcessing = false
})
}, },
submitForm() { submitForm() {
this.updateCover(this.imageUrl) this.updateCover(this.imageUrl)
}, },
async updateCover(cover) { async updateCover(cover) {
if (cover === this.coverPath) { if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
console.warn('Cover has not changed..', cover) this.$toast.error('Invalid URL')
return return
} }
this.isProcessing = true this.isProcessing = true
var success = false this.$axios
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
if (!cover) { .then(() => {
// Remove cover this.imageUrl = ''
success = await this.$axios this.$toast.success('Update Successful')
.$delete(`/api/items/${this.libraryItemId}/cover`)
.then(() => true)
.catch((error) => {
console.error('Failed to remove cover', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
// Download cover from url and use
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
}) })
} else { .catch((error) => {
// Update local cover url console.error('Failed to update cover', error)
const updatePayload = { this.$toast.error(error.response?.data || 'Failed to update cover')
cover })
} .finally(() => {
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => { this.isProcessing = false
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
}) })
}
if (success) {
this.$toast.success('Update Successful')
} else if (this.media.coverPath) {
this.imageUrl = this.media.coverPath
}
this.isProcessing = false
}, },
getSearchQuery() { getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}` var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
@ -319,7 +305,19 @@ export default {
this.hasSearched = true this.hasSearched = true
}, },
setCover(coverFile) { setCover(coverFile) {
this.updateCover(coverFile.metadata.path) this.isProcessing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
.then(() => {
this.$toast.success('Update Successful')
})
.catch((error) => {
console.error('Failed to set local cover', error)
this.$toast.error(error.response?.data || 'Failed to set cover')
})
.finally(() => {
this.isProcessing = false
})
} }
} }
} }

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Stunde", "LabelHour": "Stunde",
"LabelIcon": "Symbol", "LabelIcon": "Symbol",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "In die Titelliste aufnehmen", "LabelIncludeInTracklist": "In die Titelliste aufnehmen",
"LabelIncomplete": "Unvollständig", "LabelIncomplete": "Unvollständig",
"LabelInProgress": "In Bearbeitung", "LabelInProgress": "In Bearbeitung",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Include in Tracklist", "LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete", "LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress", "LabelInProgress": "In Progress",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hora", "LabelHour": "Hora",
"LabelIcon": "Icono", "LabelIcon": "Icono",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Incluir en Tracklist", "LabelIncludeInTracklist": "Incluir en Tracklist",
"LabelIncomplete": "Incompleto", "LabelIncomplete": "Incompleto",
"LabelInProgress": "En Proceso", "LabelInProgress": "En Proceso",

View File

@ -266,6 +266,7 @@
"LabelHost": "Hôte", "LabelHost": "Hôte",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelIcon": "Icone", "LabelIcon": "Icone",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncludeInTracklist": "Inclure dans la liste des pistes",
"LabelIncomplete": "Incomplet", "LabelIncomplete": "Incomplet",
"LabelInProgress": "En cours", "LabelInProgress": "En cours",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Include in Tracklist", "LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete", "LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress", "LabelInProgress": "In Progress",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Include in Tracklist", "LabelIncludeInTracklist": "Include in Tracklist",
"LabelIncomplete": "Incomplete", "LabelIncomplete": "Incomplete",
"LabelInProgress": "In Progress", "LabelInProgress": "In Progress",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Sat", "LabelHour": "Sat",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Dodaj u Tracklist", "LabelIncludeInTracklist": "Dodaj u Tracklist",
"LabelIncomplete": "Nepotpuno", "LabelIncomplete": "Nepotpuno",
"LabelInProgress": "U tijeku", "LabelInProgress": "U tijeku",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Ora", "LabelHour": "Ora",
"LabelIcon": "Icona", "LabelIcon": "Icona",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Includi nella Tracklist", "LabelIncludeInTracklist": "Includi nella Tracklist",
"LabelIncomplete": "Incompleta", "LabelIncomplete": "Incompleta",
"LabelInProgress": "In Corso", "LabelInProgress": "In Corso",

View File

@ -266,6 +266,7 @@
"LabelHost": "Serveris", "LabelHost": "Serveris",
"LabelHour": "Valanda", "LabelHour": "Valanda",
"LabelIcon": "Piktograma", "LabelIcon": "Piktograma",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Įtraukti į takelių sąrašą", "LabelIncludeInTracklist": "Įtraukti į takelių sąrašą",
"LabelIncomplete": "Nebaigta", "LabelIncomplete": "Nebaigta",
"LabelInProgress": "Vyksta", "LabelInProgress": "Vyksta",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Uur", "LabelHour": "Uur",
"LabelIcon": "Icoon", "LabelIcon": "Icoon",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Includeer in tracklijst", "LabelIncludeInTracklist": "Includeer in tracklijst",
"LabelIncomplete": "Incompleet", "LabelIncomplete": "Incompleet",
"LabelInProgress": "Bezig", "LabelInProgress": "Bezig",

View File

@ -266,6 +266,7 @@
"LabelHost": "Tjener", "LabelHost": "Tjener",
"LabelHour": "Time", "LabelHour": "Time",
"LabelIcon": "Ikon", "LabelIcon": "Ikon",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Inkluder i sporliste", "LabelIncludeInTracklist": "Inkluder i sporliste",
"LabelIncomplete": "Ufullstendig", "LabelIncomplete": "Ufullstendig",
"LabelInProgress": "I gang", "LabelInProgress": "I gang",

View File

@ -266,6 +266,7 @@
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Godzina", "LabelHour": "Godzina",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania", "LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
"LabelIncomplete": "Nieukończone", "LabelIncomplete": "Nieukończone",
"LabelInProgress": "W trakcie", "LabelInProgress": "W trakcie",

View File

@ -266,6 +266,7 @@
"LabelHost": "Хост", "LabelHost": "Хост",
"LabelHour": "Часы", "LabelHour": "Часы",
"LabelIcon": "Иконка", "LabelIcon": "Иконка",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncludeInTracklist": "Включать в список воспроизведения",
"LabelIncomplete": "Не завершен", "LabelIncomplete": "Не завершен",
"LabelInProgress": "В процессе", "LabelInProgress": "В процессе",

View File

@ -266,6 +266,7 @@
"LabelHost": "主机", "LabelHost": "主机",
"LabelHour": "小时", "LabelHour": "小时",
"LabelIcon": "图标", "LabelIcon": "图标",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncludeInTracklist": "包含在音轨列表中",
"LabelIncomplete": "未听完", "LabelIncomplete": "未听完",
"LabelInProgress": "正在听", "LabelInProgress": "正在听",

34
package-lock.json generated
View File

@ -18,6 +18,7 @@
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"socket.io": "^4.5.4", "socket.io": "^4.5.4",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"ssrf-req-filter": "^1.1.0",
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
"bin": { "bin": {
@ -2387,6 +2388,22 @@
} }
} }
}, },
"node_modules/ssrf-req-filter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz",
"integrity": "sha512-YUyTinAEm52NsoDvkTFN9BQIa5nURNr2aN0BwOiJxHK3tlyGUczHa+2LjcibKNugAk/losB6kXOfcRzy0LQ4uA==",
"dependencies": {
"ipaddr.js": "^2.1.0"
}
},
"node_modules/ssrf-req-filter/node_modules/ipaddr.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
"engines": {
"node": ">= 10"
}
},
"node_modules/ssri": { "node_modules/ssri": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@ -4437,6 +4454,21 @@
"tar": "^6.1.11" "tar": "^6.1.11"
} }
}, },
"ssrf-req-filter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz",
"integrity": "sha512-YUyTinAEm52NsoDvkTFN9BQIa5nURNr2aN0BwOiJxHK3tlyGUczHa+2LjcibKNugAk/losB6kXOfcRzy0LQ4uA==",
"requires": {
"ipaddr.js": "^2.1.0"
},
"dependencies": {
"ipaddr.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ=="
}
}
},
"ssri": { "ssri": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@ -4672,4 +4704,4 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
} }
} }
} }

View File

@ -39,9 +39,10 @@
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"socket.io": "^4.5.4", "socket.io": "^4.5.4",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"ssrf-req-filter": "^1.1.0",
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.20" "nodemon": "^2.0.20"
} }
} }

View File

@ -182,22 +182,22 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
var libraryItem = req.libraryItem let libraryItem = req.libraryItem
var result = null let result = null
if (req.body && req.body.url) { if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await CoverManager.downloadCoverFromUrl(libraryItem, 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`) Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await CoverManager.uploadCover(libraryItem, req.files.cover) result = await CoverManager.uploadCover(libraryItem, req.files.cover)
} else { } else {
return res.status(400).send('Invalid request no file or url') 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) return res.status(400).send(result.error)
} else if (!result || !result.cover) { } else if (!result?.cover) {
return res.status(500).send('Unknown error occurred') return res.status(500).send('Unknown error occurred')
} }

View File

@ -120,13 +120,16 @@ class CoverManager {
await fs.ensureDir(coverDirPath) await fs.ensureDir(coverDirPath)
var temppath = Path.posix.join(coverDirPath, 'cover') var temppath = Path.posix.join(coverDirPath, 'cover')
var success = await downloadFile(url, temppath).then(() => true).catch((err) => {
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) let errorMsg = ''
let success = await downloadFile(url, temppath).then(() => true).catch((err) => {
errorMsg = err.message || 'Unknown error'
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
return false return false
}) })
if (!success) { if (!success) {
return { return {
error: 'Failed to download image from url' error: 'Failed to download image from url: ' + errorMsg
} }
} }

View File

@ -1,7 +1,8 @@
const fs = require('../libs/fsExtra')
const rra = require('../libs/recursiveReaddirAsync')
const axios = require('axios') const axios = require('axios')
const Path = require('path') const Path = require('path')
const ssrfFilter = require('ssrf-req-filter')
const fs = require('../libs/fsExtra')
const rra = require('../libs/recursiveReaddirAsync')
const Logger = require('../Logger') const Logger = require('../Logger')
const { AudioMimeType } = require('./constants') const { AudioMimeType } = require('./constants')
@ -210,7 +211,9 @@ module.exports.downloadFile = (url, filepath) => {
url, url,
method: 'GET', method: 'GET',
responseType: 'stream', responseType: 'stream',
timeout: 30000 timeout: 30000,
httpAgent: ssrfFilter(url),
httpsAgent: ssrfFilter(url)
}).then((response) => { }).then((response) => {
const writer = fs.createWriteStream(filepath) const writer = fs.createWriteStream(filepath)
response.data.pipe(writer) response.data.pipe(writer)