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 -->
<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="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">
<span class="material-icons text-2xl">delete</span>
</ui-tooltip>
@ -16,15 +16,16 @@
</div>
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<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">
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
</ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">{{ $strings.ButtonSave }}</ui-btn>
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
<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>
</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">
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
<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" />
</div>
</template>
@ -165,6 +166,9 @@ export default {
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userToken() {
return this.$store.getters['user/getToken']
},
@ -222,71 +226,53 @@ export default {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.media.coverPath || ''
this.imageUrl = ''
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.media.coverPath) {
this.imageUrl = ''
if (!this.coverPath) {
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() {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
if (cover === this.coverPath) {
console.warn('Cover has not changed..', cover)
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
this.$toast.error('Invalid URL')
return
}
this.isProcessing = true
var success = false
if (!cover) {
// Remove cover
success = await this.$axios
.$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
this.$axios
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
.then(() => {
this.imageUrl = ''
this.$toast.success('Update Successful')
})
} else {
// Update local cover url
const updatePayload = {
cover
}
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
.catch((error) => {
console.error('Failed to update cover', error)
this.$toast.error(error.response?.data || 'Failed to update cover')
})
.finally(() => {
this.isProcessing = false
})
}
if (success) {
this.$toast.success('Update Successful')
} else if (this.media.coverPath) {
this.imageUrl = this.media.coverPath
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
@ -319,7 +305,19 @@ export default {
this.hasSearched = true
},
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",
"LabelHour": "Stunde",
"LabelIcon": "Symbol",
"LabelImageURLFromTheWeb": "Image URL from the web",
"LabelIncludeInTracklist": "In die Titelliste aufnehmen",
"LabelIncomplete": "Unvollständig",
"LabelInProgress": "In Bearbeitung",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

34
package-lock.json generated
View File

@ -18,6 +18,7 @@
"sequelize": "^6.32.1",
"socket.io": "^4.5.4",
"sqlite3": "^5.1.6",
"ssrf-req-filter": "^1.1.0",
"xml2js": "^0.5.0"
},
"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": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@ -4437,6 +4454,21 @@
"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": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@ -4672,4 +4704,4 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}
}

View File

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

View File

@ -182,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')
}

View File

@ -120,13 +120,16 @@ class CoverManager {
await fs.ensureDir(coverDirPath)
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
})
if (!success) {
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 Path = require('path')
const ssrfFilter = require('ssrf-req-filter')
const fs = require('../libs/fsExtra')
const rra = require('../libs/recursiveReaddirAsync')
const Logger = require('../Logger')
const { AudioMimeType } = require('./constants')
@ -210,7 +211,9 @@ module.exports.downloadFile = (url, filepath) => {
url,
method: 'GET',
responseType: 'stream',
timeout: 30000
timeout: 30000,
httpAgent: ssrfFilter(url),
httpsAgent: ssrfFilter(url)
}).then((response) => {
const writer = fs.createWriteStream(filepath)
response.data.pipe(writer)