✨ SFTP支持上传文件夹
This commit is contained in:
parent
b8da64f8dd
commit
afda15de68
@ -1,29 +1,14 @@
|
|||||||
const { Server } = require('socket.io')
|
const { Server } = require('socket.io')
|
||||||
const SFTPClient = require('ssh2-sftp-client')
|
const SFTPClient = require('ssh2-sftp-client')
|
||||||
const rawPath = require('path')
|
const rawPath = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs-extra')
|
||||||
const { readHostList, readSSHRecord, verifyAuthSync, RSADecryptSync, AESDecryptSync } = require('../utils')
|
|
||||||
|
const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync } = require('../utils')
|
||||||
const { sftpCacheDir } = require('../config')
|
const { sftpCacheDir } = require('../config')
|
||||||
const CryptoJS = require('crypto-js')
|
const CryptoJS = require('crypto-js')
|
||||||
|
|
||||||
function clearDir(path, rmSelf = false) {
|
// 读取切片
|
||||||
let files = []
|
|
||||||
if(!fs.existsSync(path)) return consola.info('clearDir: 目标文件夹不存在')
|
|
||||||
files = fs.readdirSync(path)
|
|
||||||
files.forEach((file) => {
|
|
||||||
let curPath = path + '/' + file
|
|
||||||
if(fs.statSync(curPath).isDirectory()){
|
|
||||||
clearDir(curPath) //递归删除文件夹
|
|
||||||
fs.rmdirSync(curPath) // 删除文件夹
|
|
||||||
} else {
|
|
||||||
fs.unlinkSync(curPath) //删除文件
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if(rmSelf) fs.rmdirSync(path)
|
|
||||||
consola.success('clearDir: 已清空缓存文件')
|
|
||||||
}
|
|
||||||
const pipeStream = (path, writeStream) => {
|
const pipeStream = (path, writeStream) => {
|
||||||
// console.log('path', path)
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const readStream = fs.createReadStream(path)
|
const readStream = fs.createReadStream(path)
|
||||||
readStream.on('end', () => {
|
readStream.on('end', () => {
|
||||||
@ -110,7 +95,7 @@ function listenInput(sftpClient, socket) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
socket.on('up_file', async ({ targetPath, fullPath, name, file }) => {
|
socket.on('up_file', async ({ targetPath, fullPath, name, file }) => {
|
||||||
console.log({ targetPath, fullPath, name, file })
|
// console.log({ targetPath, fullPath, name, file })
|
||||||
const exists = await sftpClient.exists(targetPath)
|
const exists = await sftpClient.exists(targetPath)
|
||||||
if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
|
if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
|
||||||
try {
|
try {
|
||||||
@ -125,43 +110,54 @@ function listenInput(sftpClient, socket) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 上传文件夹先在目标sftp服务器创建文件夹
|
||||||
|
socket.on('create_remote_dir', async ({ targetDirPath, folderName }) => {
|
||||||
|
let fullPath = rawPath.posix.join(targetDirPath, folderName)
|
||||||
|
consola.info('创建远程服务器文件夹:', fullPath)
|
||||||
|
const exists = await sftpClient.exists(fullPath)
|
||||||
|
if(exists) return socket.emit('is_exists_dir', '上传文件夹失败,文件夹已存在')
|
||||||
|
let res = await sftpClient.mkdir(fullPath)
|
||||||
|
consola.success('创建远程服务器文件夹成功:', fullPath)
|
||||||
|
socket.emit('create_remote_dir_success', res)
|
||||||
|
})
|
||||||
|
|
||||||
/** 分片上传 */
|
/** 分片上传 */
|
||||||
// 1. 创建本地缓存文件夹
|
// 1. 创建本地缓存文件夹
|
||||||
let md5List = []
|
let md5List = []
|
||||||
socket.on('create_cache_dir', async ({ targetPath, name }) => {
|
socket.on('create_cache_dir', async ({ targetDirPath, name }) => {
|
||||||
// console.log({ targetPath, name })
|
// console.log({ targetDirPath, name })
|
||||||
const exists = await sftpClient.exists(targetPath)
|
const exists = await sftpClient.exists(targetDirPath)
|
||||||
if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
|
if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
|
||||||
md5List = []
|
md5List = []
|
||||||
const localPath = rawPath.join(sftpCacheDir, name)
|
const localPath = rawPath.join(sftpCacheDir, name)
|
||||||
if(fs.existsSync(localPath)) clearDir(localPath) // 清空目录
|
fs.emptyDirSync(localPath) // 不存在会创建,存在则清空
|
||||||
fs.mkdirSync(localPath, { recursive: true })
|
|
||||||
console.log('================create_cache_success================')
|
|
||||||
socket.emit('create_cache_success')
|
socket.emit('create_cache_success')
|
||||||
})
|
})
|
||||||
|
// 2. 上传分片
|
||||||
socket.on('up_file_slice', async ({ name, sliceFile, fileIndex }) => {
|
socket.on('up_file_slice', async ({ name, sliceFile, fileIndex }) => {
|
||||||
// console.log('up_file_slice:', fileIndex, name)
|
// console.log('up_file_slice:', fileIndex, name)
|
||||||
try {
|
try {
|
||||||
let md5 = `${ fileIndex }.${ CryptoJS.MD5(fileIndex+name).toString() }`
|
let md5 = `${ fileIndex }.${ CryptoJS.MD5(fileIndex+name).toString() }`
|
||||||
const localPath = rawPath.join(sftpCacheDir, name, md5)
|
const md5LocalPath = rawPath.join(sftpCacheDir, name, md5)
|
||||||
md5List.push(localPath)
|
md5List.push(md5LocalPath)
|
||||||
fs.writeFileSync(localPath, sliceFile)
|
fs.writeFileSync(md5LocalPath, sliceFile)
|
||||||
socket.emit('up_file_slice_success', md5)
|
socket.emit('up_file_slice_success', md5)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
consola.error('up_file_slice Error', error.message)
|
consola.error('up_file_slice Error', error.message)
|
||||||
socket.emit('up_file_slice_fail', error.message)
|
socket.emit('up_file_slice_fail', error.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
socket.on('up_file_slice_over', async ({ name, fullPath, range, size }) => {
|
// 3. 完成上传
|
||||||
const resultDirPath = rawPath.join(sftpCacheDir, name)
|
socket.on('up_file_slice_over', async ({ name, targetFilePath, range, size }) => {
|
||||||
|
const md5CacheDirPath = rawPath.join(sftpCacheDir, name)
|
||||||
const resultFilePath = rawPath.join(sftpCacheDir, name, name)
|
const resultFilePath = rawPath.join(sftpCacheDir, name, name)
|
||||||
|
fs.ensureDirSync(md5CacheDirPath)
|
||||||
try {
|
try {
|
||||||
console.log('md5List: ', md5List)
|
console.log('md5List: ', md5List)
|
||||||
const arr = md5List.map((chunkFilePath, index) => {
|
const arr = md5List.map((chunkFilePath, index) => {
|
||||||
return pipeStream(
|
return pipeStream(
|
||||||
chunkFilePath,
|
chunkFilePath,
|
||||||
// 指定位置创建可写流
|
fs.createWriteStream(resultFilePath, { // 指定位置创建可写流
|
||||||
fs.createWriteStream(resultFilePath, {
|
|
||||||
start: index * range,
|
start: index * range,
|
||||||
end: (index + 1) * range
|
end: (index + 1) * range
|
||||||
})
|
})
|
||||||
@ -170,7 +166,7 @@ function listenInput(sftpClient, socket) {
|
|||||||
md5List = []
|
md5List = []
|
||||||
await Promise.all(arr)
|
await Promise.all(arr)
|
||||||
let timer = null
|
let timer = null
|
||||||
let res = await sftpClient.fastPut(resultFilePath, fullPath, {
|
let res = await sftpClient.fastPut(resultFilePath, targetFilePath, {
|
||||||
step: step => {
|
step: step => {
|
||||||
if(timer) return
|
if(timer) return
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
@ -183,11 +179,14 @@ function listenInput(sftpClient, socket) {
|
|||||||
})
|
})
|
||||||
consola.success('sftp上传成功: ', res)
|
consola.success('sftp上传成功: ', res)
|
||||||
socket.emit('up_file_success', res)
|
socket.emit('up_file_success', res)
|
||||||
clearDir(resultDirPath, true) // 传服务器后移除文件夹及其文件
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
consola.error('sftp上传失败: ', error.message)
|
consola.error('sftp上传失败: ', error.message)
|
||||||
socket.emit('up_file_fail', error.message)
|
socket.emit('up_file_fail', error.message)
|
||||||
clearDir(resultDirPath, true) // 传服务器后移除文件夹及其文件
|
} finally {
|
||||||
|
fs.remove(md5CacheDirPath)
|
||||||
|
.then(() => {
|
||||||
|
console.log('clean md5CacheDirPath:', md5CacheDirPath)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -237,6 +236,7 @@ module.exports = (httpServer) => {
|
|||||||
.connect(authInfo)
|
.connect(authInfo)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
consola.success('连接Sftp成功:', host)
|
consola.success('连接Sftp成功:', host)
|
||||||
|
fs.ensureDirSync(sftpCacheDir)
|
||||||
return sftpClient.list('/')
|
return sftpClient.list('/')
|
||||||
})
|
})
|
||||||
.then((rootLs) => {
|
.then((rootLs) => {
|
||||||
@ -260,8 +260,10 @@ module.exports = (httpServer) => {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
sftpClient = null
|
sftpClient = null
|
||||||
const cacheDir = rawPath.join(sftpCacheDir)
|
fs.emptyDir(sftpCacheDir)
|
||||||
clearDir(cacheDir)
|
.then(() => {
|
||||||
|
consola.success('clean sftpCacheDir: ', sftpCacheDir)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
"global": "^4.4.0",
|
"global": "^4.4.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"koa": "^2.15.3",
|
"koa": "^2.15.3",
|
||||||
|
@ -57,30 +57,40 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</tooltip>
|
</tooltip>
|
||||||
<tooltip content="上传到当前目录">
|
|
||||||
|
<el-dropdown trigger="click">
|
||||||
<div class="img">
|
<div class="img">
|
||||||
<img
|
<img
|
||||||
src="@/assets/image/system/upload.png"
|
src="@/assets/image/system/upload.png"
|
||||||
style=" width: 19px; height: 19px; "
|
style=" width: 19px; height: 19px; "
|
||||||
@click="uploadFileRef.click()"
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="uploadFileRef"
|
ref="uploadFileRef"
|
||||||
type="file"
|
type="file"
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
multiple
|
multiple
|
||||||
@change="handleUpload"
|
@change="handleUploadFiles"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="uploadDirRef"
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
webkitdirectory
|
||||||
|
directory
|
||||||
|
@change="handleUploadDir"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</tooltip>
|
<template #dropdown>
|
||||||
<!-- <tooltip content="搜索">
|
<el-dropdown-menu>
|
||||||
<div class="img">
|
<el-dropdown-item @click="uploadFileRef.click()">
|
||||||
<img
|
上传文件
|
||||||
src="@/assets/image/system/search.png"
|
</el-dropdown-item>
|
||||||
style="width: 20px; height: 20px; margin-top: 1px;"
|
<el-dropdown-item @click="uploadDirRef.click()">
|
||||||
>
|
上传文件夹
|
||||||
</div>
|
</el-dropdown-item>
|
||||||
</tooltip> -->
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-input">
|
<div class="filter-input">
|
||||||
<el-input
|
<el-input
|
||||||
@ -181,6 +191,7 @@ const adjustRef = ref(null)
|
|||||||
const sftpTabContainerRef = ref(null)
|
const sftpTabContainerRef = ref(null)
|
||||||
const childDirRef = ref(null)
|
const childDirRef = ref(null)
|
||||||
const uploadFileRef = ref(null)
|
const uploadFileRef = ref(null)
|
||||||
|
const uploadDirRef = ref(null)
|
||||||
|
|
||||||
const token = computed(() => $store.token)
|
const token = computed(() => $store.token)
|
||||||
const curPath = computed(() => paths.value.join('/').replace(/\/{2,}/g, '/'))
|
const curPath = computed(() => paths.value.join('/').replace(/\/{2,}/g, '/'))
|
||||||
@ -325,6 +336,7 @@ const listenSftp = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openRootChild = (item) => {
|
const openRootChild = (item) => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
const { name, type } = item
|
const { name, type } = item
|
||||||
if (isDir(type)) {
|
if (isDir(type)) {
|
||||||
childDirLoading.value = true
|
childDirLoading.value = true
|
||||||
@ -341,6 +353,7 @@ const openRootChild = (item) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openTarget = (item) => {
|
const openTarget = (item) => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
const { name, type, size } = item
|
const { name, type, size } = item
|
||||||
if (isDir(type)) {
|
if (isDir(type)) {
|
||||||
paths.value.push(name)
|
paths.value.push(name)
|
||||||
@ -375,6 +388,7 @@ const selectFile = (item) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleReturn = () => {
|
const handleReturn = () => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
if (paths.value.length === 1) return
|
if (paths.value.length === 1) return
|
||||||
paths.value.pop()
|
paths.value.pop()
|
||||||
openDir()
|
openDir()
|
||||||
@ -385,6 +399,7 @@ const handleRefresh = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
if (curTarget.value === null) return $message.warning('先选择一个文件')
|
if (curTarget.value === null) return $message.warning('先选择一个文件')
|
||||||
const { name, size, type } = curTarget.value
|
const { name, size, type } = curTarget.value
|
||||||
if (isDir(type)) return $message.error('暂不支持下载文件夹')
|
if (isDir(type)) return $message.error('暂不支持下载文件夹')
|
||||||
@ -407,6 +422,7 @@ const handleDownload = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
if (curTarget.value === null) return $message.warning('先选择一个文件(夹)')
|
if (curTarget.value === null) return $message.warning('先选择一个文件(夹)')
|
||||||
const { name, type } = curTarget.value
|
const { name, type } = curTarget.value
|
||||||
$messageBox.confirm(`确认删除:${ name }`, 'Warning', {
|
$messageBox.confirm(`确认删除:${ name }`, 'Warning', {
|
||||||
@ -424,38 +440,62 @@ const handleDelete = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpload = async (event) => {
|
const handleUploadFiles = async (event) => {
|
||||||
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
let { files } = event.target
|
let { files } = event.target
|
||||||
|
|
||||||
for (let file of files) {
|
for (let file of files) {
|
||||||
try {
|
try {
|
||||||
await uploadFile(file)
|
const targetFilePath = getPath(file.name)
|
||||||
|
await uploadFile(file, targetFilePath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$message.error(error)
|
$message.error(`${ file.name }上传失败: ${ error }`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uploadFileRef.value = null
|
uploadFileRef.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadFile = (file) => {
|
const handleUploadDir = async (event) => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
|
let { files } = event.target
|
||||||
|
if(files.length === 0) return $message.warning('不允许上传空文件夹')
|
||||||
|
let folderName = files[0].webkitRelativePath.split('/')[0]
|
||||||
|
console.log(folderName)
|
||||||
|
console.log(files)
|
||||||
|
let targetDirPath = curPath.value
|
||||||
|
socket.value.emit('create_remote_dir', { targetDirPath, folderName })
|
||||||
|
socket.value.once('is_exists_dir', (res) => { $message.error(res) })
|
||||||
|
socket.value.once('create_remote_dir_success', async () => {
|
||||||
|
for (let file of files) {
|
||||||
|
let fullFilePath = getPath(`${ folderName }/${ file.name }`)
|
||||||
|
try {
|
||||||
|
await uploadFile(file, fullFilePath)
|
||||||
|
} catch (error) {
|
||||||
|
$message.error(`${ file.name }上传失败: ${ error }`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploadDirRef.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFile = (file, targetFilePath) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!file) return reject('file is not defined')
|
if (!file) return reject('file is not defined')
|
||||||
if ((file.size / 1024 / 1024) > 1000) {
|
// if ((file.size / 1024 / 1024) > 1000) {
|
||||||
$message.warn('用网页传这么大文件你是认真的吗?')
|
// $message.warn('用网页传这么大文件你是认真的吗?')
|
||||||
}
|
// }
|
||||||
let reader = new
|
let reader = new FileReader()
|
||||||
FileReader()
|
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
const { name } = file
|
const { name } = file
|
||||||
const fullPath = getPath(name)
|
const targetDirPath = curPath.value
|
||||||
const targetPath = curPath.value
|
|
||||||
curUploadFileName.value = name
|
curUploadFileName.value = name
|
||||||
socket.value.emit('create_cache_dir', { targetPath, name })
|
const size = file.size
|
||||||
|
if(size === 0) return reject('文件大小为0KB, 无法上传')
|
||||||
|
socket.value.emit('create_cache_dir', { targetDirPath, name })
|
||||||
socket.value.once('create_cache_success', async () => {
|
socket.value.once('create_cache_success', async () => {
|
||||||
let start = 0
|
let start = 0
|
||||||
let end = 0
|
let end = 0
|
||||||
const range = 1024 * 512 // 每段512KB
|
const range = 1024 * 512 // 每段512KB
|
||||||
const size = file.size
|
|
||||||
let fileIndex = 0
|
let fileIndex = 0
|
||||||
let multipleFlag = false
|
let multipleFlag = false
|
||||||
try {
|
try {
|
||||||
@ -471,7 +511,7 @@ const uploadFile = (file) => {
|
|||||||
await uploadSliceFile({ name, sliceFile, fileIndex })
|
await uploadSliceFile({ name, sliceFile, fileIndex })
|
||||||
upFileProgress.value = parseInt((fileIndex / totalSliceCount * 100) / 2)
|
upFileProgress.value = parseInt((fileIndex / totalSliceCount * 100) / 2)
|
||||||
}
|
}
|
||||||
socket.value.emit('up_file_slice_over', { name, fullPath, range, size })
|
socket.value.emit('up_file_slice_over', { name, targetFilePath, range, size })
|
||||||
socket.value.once('up_file_success', () => {
|
socket.value.once('up_file_success', () => {
|
||||||
if (multipleFlag) return
|
if (multipleFlag) return
|
||||||
handleRefresh()
|
handleRefresh()
|
||||||
@ -523,6 +563,7 @@ const uploadSliceFile = (fileInfo) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openDir = (path = '', tips = true) => {
|
const openDir = (path = '', tips = true) => {
|
||||||
|
if (showFileProgress.value) return $message.warning('需等待当前任务完成')
|
||||||
childDirLoading.value = true
|
childDirLoading.value = true
|
||||||
curTarget.value = null
|
curTarget.value = null
|
||||||
socket.value.emit('open_dir', path || curPath.value, tips)
|
socket.value.emit('open_dir', path || curPath.value, tips)
|
||||||
|
@ -2524,6 +2524,15 @@ fs-extra@^10.0.0:
|
|||||||
jsonfile "^6.0.1"
|
jsonfile "^6.0.1"
|
||||||
universalify "^2.0.0"
|
universalify "^2.0.0"
|
||||||
|
|
||||||
|
fs-extra@^11.2.0:
|
||||||
|
version "11.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b"
|
||||||
|
integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==
|
||||||
|
dependencies:
|
||||||
|
graceful-fs "^4.2.0"
|
||||||
|
jsonfile "^6.0.1"
|
||||||
|
universalify "^2.0.0"
|
||||||
|
|
||||||
fs-extra@^8.1.0:
|
fs-extra@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
|
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user