Feature: webdav picbed now support digest auth

This commit is contained in:
萌萌哒赫萝
2023-09-12 19:27:59 -07:00
parent 09a636f414
commit 2e655a4ebd
15 changed files with 408 additions and 94 deletions

View File

@@ -2,7 +2,7 @@
import ManageLogger from '../utils/logger'
// WebDAV 客户端库
import { createClient, WebDAVClient, FileStat, ProgressEvent } from 'webdav'
import { createClient, WebDAVClient, FileStat, ProgressEvent, AuthType, WebDAVClientOptions } from 'webdav'
// 错误格式化函数、端点地址格式化函数、获取内部代理、新的下载器、并发异步任务池
import { formatError, formatEndpoint, getInnerAgent, NewDownloader, ConcurrencyPromisePool } from '../utils/common'
@@ -34,6 +34,7 @@ import path from 'path'
// 取消下载任务的加载文件列表、刷新下载文件传输列表
import { cancelDownloadLoadingFileList, refreshDownloadFileTransferList } from '@/manage/utils/static'
import { getAuthHeader } from '@/manage/utils/digestAuth'
class WebdavplistApi {
endpoint: string
@@ -42,29 +43,35 @@ class WebdavplistApi {
sslEnabled: boolean
proxy: string | undefined
proxyStr: string | undefined
authType: 'basic' | 'digest' | undefined
logger: ManageLogger
agent: https.Agent | http.Agent
ctx: WebDAVClient
constructor (endpoint: string, username: string, password: string, sslEnabled: boolean, proxy: string | undefined, logger: ManageLogger) {
constructor (endpoint: string, username: string, password: string, sslEnabled: boolean, proxy: string | undefined, authType: 'basic' | 'digest' | undefined, logger: ManageLogger) {
this.endpoint = formatEndpoint(endpoint, sslEnabled)
this.username = username
this.password = password
this.sslEnabled = sslEnabled
this.proxy = proxy
this.proxyStr = formatHttpProxy(proxy, 'string') as string | undefined
this.authType = authType || 'basic'
this.logger = logger
this.agent = getInnerAgent(proxy, sslEnabled).agent
const options: WebDAVClientOptions = {
username: this.username,
password: this.password,
maxBodyLength: 4 * 1024 * 1024 * 1024,
maxContentLength: 4 * 1024 * 1024 * 1024,
httpsAgent: sslEnabled ? this.agent : undefined,
httpAgent: !sslEnabled ? this.agent : undefined
}
if (this.authType === 'digest') {
options.authType = AuthType.Digest
}
this.ctx = createClient(
this.endpoint,
{
username: this.username,
password: this.password,
maxBodyLength: 4 * 1024 * 1024 * 1024,
maxContentLength: 4 * 1024 * 1024 * 1024,
httpsAgent: sslEnabled ? this.agent : undefined,
httpAgent: !sslEnabled ? this.agent : undefined
}
options
)
}
@@ -275,7 +282,7 @@ class WebdavplistApi {
})
this.ctx.putFileContents(
key,
fs.createReadStream(filePath),
this.authType === 'digest' ? fs.readFileSync(filePath) : fs.createReadStream(filePath),
{
overwrite: true,
onUploadProgress: (progressEvent: ProgressEvent) => {
@@ -347,12 +354,21 @@ class WebdavplistApi {
sourceFileName: fileName,
targetFilePath: savedFilePath
})
const preSignedUrl = await this.getPreSignedUrl({
let preSignedUrl = await this.getPreSignedUrl({
key
})
const base64Str = Buffer.from(`${this.username}:${this.password}`).toString('base64')
const headers = {
Authorization: `Basic ${base64Str}`
let headers = {} as IStringKeyMap
if (this.authType === 'basic' || !this.authType) {
const base64Str = Buffer.from(`${this.username}:${this.password}`).toString('base64')
headers = {
Authorization: `Basic ${base64Str}`
}
} else if (this.authType === 'digest') {
const authHeader = await getAuthHeader('GET', this.endpoint, `/${key.replace(/^\/+/, '')}`, this.username, this.password)
headers = {
Authorization: authHeader
}
preSignedUrl = `${this.endpoint}/${key.replace(/^\/+/, '')}`
}
promises.push(() => new Promise((resolve, reject) => {
NewDownloader(instance, preSignedUrl, id, savedFilePath, this.logger, this.proxyStr, headers)

View File

@@ -75,7 +75,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
case 'upyun':
return new API.UpyunApi(this.currentPicBedConfig.bucketName, this.currentPicBedConfig.operator, this.currentPicBedConfig.password, this.logger, this.currentPicBedConfig.antiLeechToken, this.currentPicBedConfig.expireTime)
case 'webdavplist':
return new API.WebdavplistApi(this.currentPicBedConfig.endpoint, this.currentPicBedConfig.username, this.currentPicBedConfig.password, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.proxy, this.logger)
return new API.WebdavplistApi(this.currentPicBedConfig.endpoint, this.currentPicBedConfig.username, this.currentPicBedConfig.password, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.proxy, this.currentPicBedConfig.authType, this.logger)
default:
return {} as any
}

View File

@@ -1,16 +1,20 @@
import { createClient } from 'webdav'
import { AuthType, WebDAVClientOptions, createClient } from 'webdav'
import { formatEndpoint } from '~/main/manage/utils/common'
export default class WebdavApi {
static async delete (configMap: IStringKeyMap): Promise<boolean> {
const { fileName, config: { host, username, password, path, sslEnabled } } = configMap
const { fileName, config: { host, username, password, path, sslEnabled, authType } } = configMap
const endpoint = formatEndpoint(host, sslEnabled)
const options: WebDAVClientOptions = {
username,
password
}
if (authType === 'digest') {
options.authType = AuthType.Digest
}
const ctx = createClient(
endpoint,
{
username,
password
}
options
)
let key
if (path === '/' || !path) {

View File

@@ -23,6 +23,8 @@
import { ref, onMounted, watch, computed } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
import { Loading } from '@element-plus/icons-vue'
import { getAuthHeader } from '@/manage/utils/digestAuth'
import { formatEndpoint } from '~/main/manage/utils/common'
const base64Url = ref('')
const success = ref(false)
@@ -41,7 +43,7 @@ const props = defineProps(
type: String,
required: true
},
headers: {
config: {
type: Object,
required: true
}
@@ -56,9 +58,31 @@ const imageSource = computed(() => {
const iconPath = computed(() => require(`../manage/pages/assets/icons/${getFileIconPath(props.item.fileName ?? '')}`))
async function getheaderOfWebdav (key: string) {
let headers = {} as any
if (props.config.authType === 'digest') {
const authHeader = await getAuthHeader(
'GET',
formatEndpoint(props.config.endpoint, props.config.sslEnabled || false),
`/${key.replace(/^\//, '')}`,
props.config.username,
props.config.password
)
headers = {
Authorization: authHeader
}
} else {
headers = {
Authorization: 'Basic ' + Buffer.from(`${props.config.username}:${props.config.password}`).toString('base64')
}
}
return headers
}
const fetchImage = async () => {
try {
const res = await fetch(props.url, { method: 'GET', headers: props.headers })
const headers = await getheaderOfWebdav(props.item.key)
const res = await fetch(props.url, { method: 'GET', headers })
if (res.status >= 200 && res.status < 300) {
const blob = await res.blob()
success.value = true
@@ -72,7 +96,7 @@ const fetchImage = async () => {
}
}
watch(() => [props.url, props.headers], fetchImage, { deep: true })
watch(() => [props.url, props.item], fetchImage, { deep: true })
onMounted(fetchImage)

View File

@@ -0,0 +1,102 @@
import { defineComponent, ref, onMounted, watch, computed } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
import { Loading } from '@element-plus/icons-vue'
import { getAuthHeader } from '@/manage/utils/digestAuth'
import { formatEndpoint } from '~/main/manage/utils/common'
import { ElImage, ElIcon } from 'element-plus'
export default defineComponent({
props: {
isShowThumbnail: {
type: Boolean,
required: true
},
item: {
type: Object,
required: true
},
url: {
type: String,
required: true
},
config: {
type: Object,
required: true
}
},
setup (props) {
const base64Url = ref('')
const success = ref(false)
const imageSource = computed(() => {
return (props.isShowThumbnail && props.item.isImage && success.value)
? base64Url.value
: require(`../manage/pages/assets/icons/${getFileIconPath(props.item.fileName ?? '')}`)
})
const iconPath = computed(() => require(`../manage/pages/assets/icons/${getFileIconPath(props.item.fileName ?? '')}`))
async function getheaderOfWebdav (key: string) {
let headers = {} as any
if (props.config.authType === 'digest') {
const authHeader = await getAuthHeader(
'GET',
formatEndpoint(props.config.endpoint, props.config.sslEnabled || false),
`/${key.replace(/^\//, '')}`,
props.config.username,
props.config.password
)
headers = {
Authorization: authHeader
}
} else {
headers = {
Authorization: 'Basic ' + Buffer.from(`${props.config.username}:${props.config.password}`).toString('base64')
}
}
return headers
}
const fetchImage = async () => {
try {
const headers = await getheaderOfWebdav(props.item.key)
const res = await fetch(props.url, { method: 'GET', headers })
if (res.status >= 200 && res.status < 300) {
const blob = await res.blob()
success.value = true
base64Url.value = URL.createObjectURL(blob)
} else {
throw new Error('Network response was not ok.')
}
} catch (err) {
success.value = false
console.log(err)
}
}
watch(() => [props.url, props.item], fetchImage, { deep: true })
onMounted(fetchImage)
return () => (
<ElImage
src={imageSource.value}
fit="contain"
style="height: 100px;width: 100%;margin: 0 auto;"
>
{{
placeholder: () => (
<ElIcon>
<Loading />
</ElIcon>
),
error: () => (
<ElImage
src={iconPath.value}
fit="contain"
style="height: 100px;width: 100%;margin: 0 auto;"
/>
)
}}
</ElImage>
)
}
})

View File

@@ -546,7 +546,7 @@ https://www.baidu.com/img/bd_logo1.png"
v-else-if="!item.isDir && currentPicBedName === 'webdavplist' && item.isImage"
:is-show-thumbnail="isShowThumbnail"
:item="item"
:headers="getBase64ofWebdav()"
:config="handleGetWebdavConfig()"
:url="item.url"
@click="handleClickFile(item)"
/>
@@ -718,6 +718,7 @@ https://www.baidu.com/img/bd_logo1.png"
:initial-index="getCurrentPreviewIndex"
infinite
hide-on-click-modal
teleported
@close="isShowImagePreview = false"
/>
<el-dialog
@@ -1542,6 +1543,7 @@ import { videoExt } from '../utils/videofile'
// 组件
import ImageWebdav from '@/components/ImageWebdav.vue'
import ImageLocal from '@/components/ImageLocal.vue'
import ImageWebdavTsx from '@/components/ImageWebdavTsx'
// 国际化函数
import { T as $T } from '@/i18n'
@@ -1660,6 +1662,8 @@ const batchRenameReplace = ref('')
const isRenameIncludeExt = ref(false)
const isSingleRename = ref(false)
const itemToBeRenamed = ref({} as any)
let fileTransferInterval: NodeJS.Timer | null = null
let downloadInterval: NodeJS.Timer | null = null
// 当前页面信息相关
const currentPicBedName = computed<string>(() => manageStore.config.picBed[configMap.alias].picBedName)
@@ -1711,6 +1715,10 @@ function stopRefreshUploadTask () {
refreshUploadTaskId.value && clearInterval(refreshUploadTaskId.value)
}
function handleGetWebdavConfig () {
return manageStore.config.picBed[configMap.alias]
}
// 下载相关函数
function showDownloadDialog () {
@@ -1736,13 +1744,6 @@ function handleViewChange (val: 'list' | 'grid') {
layoutStyle.value = val
}
function getBase64ofWebdav () {
const headers = {
Authorization: 'Basic ' + Buffer.from(`${manageStore.config.picBed[configMap.alias].username}:${manageStore.config.picBed[configMap.alias].password}`).toString('base64')
}
return headers
}
// 上传文件选择相关
function openFileSelectDialog () {
@@ -2468,13 +2469,13 @@ async function handleFolderBatchDownload (item: any) {
ipcRenderer.on(refreshDownloadFileTransferList, (evt: IpcRendererEvent, data) => {
downloadFileTransferStore.refreshDownloadFileTransferList(data)
})
const interval = setInterval(() => {
downloadInterval = setInterval(() => {
const currentFileList = downloadFileTransferStore.getDownloadFileTransferList()
currentDownloadFileList.length = 0
currentDownloadFileList.push(...currentFileList)
if (downloadFileTransferStore.isFinished()) {
clearInterval(interval)
if (downloadFileTransferStore.isFinished() && downloadInterval) {
isLoadingDownloadData.value = false
clearInterval(downloadInterval)
if (downloadFileTransferStore.isSuccess()) {
ElNotification.success({
title: $T('MANAGE_BUCKET_DOWNLOAD_FOLDER_BOX_TIP'),
@@ -2845,7 +2846,7 @@ async function getBucketFileListBackStage () {
ipcRenderer.on('refreshFileTransferList', (evt: IpcRendererEvent, data) => {
fileTransferStore.refreshFileTransferList(data)
})
const interval = setInterval(() => {
fileTransferInterval = setInterval(() => {
const currentFileList = fileTransferStore.getFileTransferList()
currentPageFilesInfo.splice(0, currentPageFilesInfo.length, ...currentFileList)
const sortType = localStorage.getItem('sortType') as sortTypeList || 'init'
@@ -2857,9 +2858,9 @@ async function getBucketFileListBackStage () {
fullList: currentPageFilesInfo
}))
})
if (fileTransferStore.isFinished()) {
clearInterval(interval)
if (fileTransferStore.isFinished() && fileTransferInterval) {
isLoadingData.value = false
clearInterval(fileTransferInterval)
if (fileTransferStore.isSuccess()) {
ElNotification.success({
title: $T('MANAGE_BUCKET_GET_FILE_BS_NOT_TITLE'),
@@ -3411,23 +3412,36 @@ const columns: Column<any>[] = [
{{
reference: () => (
!item.isDir
? <ElImage
src={isShowThumbnail.value ? item.isImage ? item.url : require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`) : require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`)}
fit="contain"
style={{ width: '20px', height: '20px' }}
>
{{
placeholder: () => <ElIcon>
<Loading />
</ElIcon>,
error: () =>
<ElImage
src={require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`)}
fit="contain"
style={{ width: '20px', height: '20px' }}
/>
}}
</ElImage>
? currentPicBedName.value !== 'webdavplist'
? <ElImage
src={isShowThumbnail.value ? item.isImage ? item.url : require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`) : require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`)}
fit="contain"
style={{ width: '20px', height: '20px' }}
>
{{
placeholder: () => <ElIcon>
<Loading />
</ElIcon>,
error: () =>
<ElImage
src={require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`)}
fit="contain"
style={{ width: '20px', height: '20px' }}
/>
}}
</ElImage>
: item.isImage
? <ImageWebdavTsx
isShowThumbnail={isShowThumbnail.value}
item={item}
config={handleGetWebdavConfig()}
url={item.url}
/>
: <ElImage
src={require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`)}
fit="contain"
style={{ width: '20px', height: '20px' }}
></ElImage>
: <ElImage
src={require('./assets/icons/folder.webp')}
fit="contain"
@@ -3435,22 +3449,29 @@ const columns: Column<any>[] = [
/>
),
default: () => (
<ElImage
src={item.isImage ? item.url : require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`) }
fit="contain"
>
{{
placeholder: () => (<ElIcon>
<Loading />
</ElIcon>
),
error: () => (
<ElIcon>
<CircleClose />
currentPicBedName.value === 'webdavplist' && item.isImage
? <ImageWebdavTsx
isShowThumbnail={isShowThumbnail.value}
item={item}
config={handleGetWebdavConfig()}
url={item.url}
/>
: <ElImage
src={item.isImage ? item.url : require(`./assets/icons/${getFileIconPath(item.fileName ?? '')}`) }
fit="contain"
>
{{
placeholder: () => (<ElIcon>
<Loading />
</ElIcon>
)
}}
</ElImage>
),
error: () => (
<ElIcon>
<CircleClose />
</ElIcon>
)
}}
</ElImage>
)
}}
</ElPopover>
@@ -3670,6 +3691,10 @@ onBeforeMount(async () => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleDetectShiftKey)
document.removeEventListener('keyup', handleDetectShiftKey)
fileTransferInterval && clearInterval(fileTransferInterval)
downloadInterval && clearInterval(downloadInterval)
refreshUploadTaskId.value && clearInterval(refreshUploadTaskId.value)
refreshDownloadTaskId.value && clearInterval(refreshDownloadTaskId.value)
if (isLoadingData.value) {
ipcRenderer.send('cancelLoadingFileList', cancelToken.value)
}

View File

@@ -576,17 +576,20 @@ async function getCurrentConfigList () {
const config = configList[pb]
return config?.configList?.length ? config.configList.map((item: any) => ({ ...item, type: pb })) : []
})
await getAllConfigAliasArray()
const autoImport = await getPicBedsConfig<boolean>('settings.autoImport') || false
if (!autoImport) return
const autoImportPicBed = initArray(await getPicBedsConfig<string | string[]>('settings.autoImportPicBed') || '', [])
await Promise.all(filteredConfigList.flatMap((config) => transUpToManage(config, config.type, autoImportPicBed)))
if (Object.keys(importedNewConfig).length > 0) {
const oldConfig = await getConfig<any>('picBed')
const newConfig = { ...oldConfig, ...importedNewConfig }
saveConfig('picBed', newConfig)
await manageStore.refreshConfig()
if (autoImport) {
const autoImportPicBed = initArray(await getPicBedsConfig<string | string[]>('settings.autoImportPicBed') || '', [])
await Promise.all(filteredConfigList.flatMap((config) => transUpToManage(config, config.type, autoImportPicBed)))
if (Object.keys(importedNewConfig).length > 0) {
const oldConfig = await getConfig<any>('picBed')
const newConfig = { ...oldConfig, ...importedNewConfig }
saveConfig('picBed', newConfig)
await manageStore.refreshConfig()
}
}
await getAllConfigAliasArray()
}
function isImported (alias: string) {
@@ -721,6 +724,7 @@ async function transUpToManage (config: IUploaderConfigListItem, picBedName: str
webPath: config.webpath || '',
customUrl: config.customUrl || '',
sslEnabled: !!config.sslEnabled,
authType: config.authType || 'basic',
proxy: '',
transformedConfig: JSON.stringify({
webdav: {

View File

@@ -779,10 +779,20 @@ export const supportedPicBedList: IStringKeyMap = {
default: true,
type: 'boolean',
tooltip: $T('MANAGE_CONSTANT_WEBDAV_SSL_TOOLTIP')
},
authType: {
required: true,
description: $T('MANAGE_CONSTANT_WEBDAV_AUTH_TYPE_DESC'),
default: 'basic',
type: 'select',
selectOptions: {
basic: 'Basic',
digest: 'Digest'
}
}
},
explain: $T('MANAGE_CONSTANT_WEBDAV_EXPLAIN'),
options: ['alias', 'endpoint', 'username', 'password', 'bucketName', 'baseDir', 'customUrl', 'webPath', 'proxy', 'sslEnabled'],
options: ['alias', 'endpoint', 'username', 'password', 'bucketName', 'baseDir', 'customUrl', 'webPath', 'proxy', 'sslEnabled', 'authType'],
refLink: 'https://piclist.cn/manage.html#webdav',
referenceText: $T('MANAGE_CONSTANT_WEBDAV_REFER_TEXT')
},

View File

@@ -0,0 +1,74 @@
import crypto from 'crypto'
import axios from 'axios'
const AUTH_KEY_VALUE_RE = /(\w+)=["']?([^'"]{1,10000})["']?/
let NC = 0
const NC_PAD = '00000000'
function md5 (text: crypto.BinaryLike) {
return crypto.createHash('md5').update(text).digest('hex')
}
export function digestAuthHeader (method: string, uri: string, wwwAuthenticate: string, username: string, password: string) {
const parts = wwwAuthenticate.split(',')
const opts = {} as IStringKeyMap
for (let i = 0; i < parts.length; i++) {
const m = AUTH_KEY_VALUE_RE.exec(parts[i])
if (m) {
opts[m[1]] = m[2].replace(/["']/g, '')
}
}
if (!opts.realm || !opts.nonce) {
return ''
}
let qop = opts.qop || ''
const userpassArray = [username, password]
let nc = String(++NC)
nc = NC_PAD.substring(nc.length) + nc
const cnonce = crypto.randomBytes(8).toString('hex')
const ha1 = md5(userpassArray[0] + ':' + opts.realm + ':' + userpassArray[1])
const ha2 = md5(method.toUpperCase() + ':' + uri)
let s = ha1 + ':' + opts.nonce
if (qop) {
qop = qop.split(',')[0]
s += ':' + nc + ':' + cnonce + ':' + qop
}
s += ':' + ha2
const response = md5(s)
let authstring =
'Digest username="' +
userpassArray[0] +
'", realm="' +
opts.realm +
'", nonce="' +
opts.nonce +
'", uri="' +
uri +
'", response="' +
response +
'"'
if (opts.opaque) {
authstring += ', opaque="' + opts.opaque + '"'
}
if (qop) {
authstring += ', qop=' + qop + ', nc=' + nc + ', cnonce="' + cnonce + '"'
}
return authstring
}
export async function getAuthHeader (method: string, host: string, uri: string, username: string, password: string) {
try {
await axios.get(
`${host}${uri}`
)
} catch (error: any) {
if (error.response.status === 401 && error.response.headers['www-authenticate']) {
return digestAuthHeader(method, uri, error.response.headers['www-authenticate'], username, password)
}
}
}

View File

@@ -579,6 +579,7 @@ interface ILocales {
MANAGE_CONSTANT_WEBDAV_PROXY_TOOLTIP: string
MANAGE_CONSTANT_WEBDAV_SSL_DESC: string
MANAGE_CONSTANT_WEBDAV_SSL_TOOLTIP: string
MANAGE_CONSTANT_WEBDAV_AUTH_TYPE_DESC: string
MANAGE_CONSTANT_WEBDAV_EXPLAIN: string
MANAGE_CONSTANT_WEBDAV_REFER_TEXT: string
MANAGE_CONSTANT_LOCAL_NAME: string