mirror of
https://github.com/Kuingsmile/PicList.git
synced 2026-09-06 07:56:56 +08:00
🚧 WIP: add local path picbed for manage page
This commit is contained in:
@@ -154,42 +154,21 @@ export const deleteChoosedFiles = async (list: ImgInfo[]): Promise<boolean[]> =>
|
||||
const file = await dbStore.removeById(item.id)
|
||||
if (await picgo.getConfig('settings.deleteCloudFile')) {
|
||||
if (item.type !== undefined && picBedsCanbeDeleted.includes(item.type)) {
|
||||
const noteFunc = (value: boolean) => {
|
||||
const notification = new Notification({
|
||||
title: T('MANAGE_BUCKET_BATCH_DELETE_ERROR_MSG_MSG2'),
|
||||
body: T(value ? 'GALLERY_SYNC_DELETE_NOTICE_SUCCEED' : 'GALLERY_SYNC_DELETE_NOTICE_FAILED')
|
||||
})
|
||||
notification.show()
|
||||
}
|
||||
if (item.type === 'webdavplist') {
|
||||
const { fileName, config } = item
|
||||
setTimeout(() => {
|
||||
deleteWebdavFile(getRawData(config), fileName || '').then((value: boolean) => {
|
||||
if (value) {
|
||||
const notification = new Notification({
|
||||
title: T('MANAGE_BUCKET_BATCH_DELETE_ERROR_MSG_MSG2'),
|
||||
body: T('GALLERY_SYNC_DELETE_NOTICE_SUCCEED')
|
||||
})
|
||||
notification.show()
|
||||
} else {
|
||||
const notification = new Notification({
|
||||
title: T('MANAGE_BUCKET_BATCH_DELETE_ERROR_MSG_MSG2'),
|
||||
body: T('GALLERY_SYNC_DELETE_NOTICE_FAILED')
|
||||
})
|
||||
notification.show()
|
||||
}
|
||||
})
|
||||
deleteWebdavFile(getRawData(config), fileName || '').then(noteFunc)
|
||||
}, 0)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
ALLApi.delete(item).then((value: boolean) => {
|
||||
if (value) {
|
||||
const notification = new Notification({
|
||||
title: T('MANAGE_BUCKET_BATCH_DELETE_ERROR_MSG_MSG2'),
|
||||
body: T('GALLERY_SYNC_DELETE_NOTICE_SUCCEED')
|
||||
})
|
||||
notification.show()
|
||||
} else {
|
||||
const notification = new Notification({
|
||||
title: T('MANAGE_BUCKET_BATCH_DELETE_ERROR_MSG_MSG2'),
|
||||
body: T('GALLERY_SYNC_DELETE_NOTICE_FAILED')
|
||||
})
|
||||
notification.show()
|
||||
}
|
||||
})
|
||||
ALLApi.delete(item).then(noteFunc)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AliyunApi from './aliyun'
|
||||
import GithubApi from './github'
|
||||
import ImgurApi from './imgur'
|
||||
import LocalApi from './local'
|
||||
import QiniuApi from './qiniu'
|
||||
import S3plistApi from './s3plist'
|
||||
import SmmsApi from './smms'
|
||||
@@ -12,6 +13,7 @@ export default {
|
||||
AliyunApi,
|
||||
GithubApi,
|
||||
ImgurApi,
|
||||
LocalApi,
|
||||
QiniuApi,
|
||||
S3plistApi,
|
||||
SmmsApi,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// 日志记录器
|
||||
import ManageLogger from '../utils/logger'
|
||||
|
||||
// 错误格式化函数、端点地址格式化函数、获取内部代理、新的下载器、并发异步任务池
|
||||
import { formatError } from '../utils/common'
|
||||
|
||||
// HTTP 代理格式化函数、是否为图片的判断函数
|
||||
import { isImage } from '@/manage/utils/common'
|
||||
|
||||
// 窗口管理器
|
||||
import windowManager from 'apis/app/window/windowManager'
|
||||
|
||||
// 枚举类型声明
|
||||
import { IWindowList } from '#/types/enum'
|
||||
|
||||
// Electron 相关
|
||||
import { ipcMain, IpcMainEvent } from 'electron'
|
||||
|
||||
// 上传下载任务队列
|
||||
import UpDownTaskQueue, { uploadTaskSpecialStatus, commonTaskStatus, downloadTaskSpecialStatus } from '../datastore/upDownTaskQueue'
|
||||
|
||||
// 文件系统库
|
||||
import fs from 'fs-extra'
|
||||
|
||||
// 路径处理库
|
||||
import path from 'path'
|
||||
import * as fsWalk from '@nodelib/fs.walk'
|
||||
|
||||
// 取消下载任务的加载文件列表、刷新下载文件传输列表
|
||||
import { cancelDownloadLoadingFileList, refreshDownloadFileTransferList } from '@/manage/utils/static'
|
||||
|
||||
class LocalApi {
|
||||
logger: ManageLogger
|
||||
isWindows: boolean
|
||||
|
||||
constructor (logger: ManageLogger) {
|
||||
this.logger = logger
|
||||
this.isWindows = process.platform === 'win32'
|
||||
}
|
||||
|
||||
logParam = (error:any, method: string) =>
|
||||
this.logger.error(formatError(error, { class: 'LocalApi', method }))
|
||||
|
||||
// windows 系统下将路径转换为 unix 风格
|
||||
transPathToUnix (filePath: string | undefined) {
|
||||
if (!filePath) return ''
|
||||
return this.isWindows ? filePath.split(path.sep).join(path.posix.sep) : filePath.replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
transBack (filePath: string | undefined) {
|
||||
if (!filePath) return ''
|
||||
return this.isWindows
|
||||
? filePath.split(path.posix.sep).join(path.sep).replace(/^\\+/, '')
|
||||
: `/${filePath.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
formatFolder (item: fs.Stats, urlPrefix: string, fileName: string, filePath: string) {
|
||||
const key = this.transPathToUnix(filePath)
|
||||
return {
|
||||
...item,
|
||||
key,
|
||||
fileName,
|
||||
fileSize: 0,
|
||||
Key: key,
|
||||
formatedTime: '',
|
||||
isDir: true,
|
||||
checked: false,
|
||||
isImage: false,
|
||||
match: false,
|
||||
url: urlPrefix
|
||||
}
|
||||
}
|
||||
|
||||
formatFile (item: fs.Stats, urlPrefix: string, fileName: string, filePath: string) {
|
||||
const key = this.transPathToUnix(filePath)
|
||||
return {
|
||||
...item,
|
||||
key,
|
||||
fileName,
|
||||
fileSize: item.size,
|
||||
Key: key,
|
||||
formatedTime: new Date(item.mtime).toLocaleString(),
|
||||
isDir: false,
|
||||
checked: false,
|
||||
match: false,
|
||||
isImage: isImage(fileName),
|
||||
url: urlPrefix
|
||||
}
|
||||
}
|
||||
|
||||
async getBucketListRecursively (configMap: IStringKeyMap): Promise<any> {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const { prefix, customUrl = '', cancelToken } = configMap
|
||||
const urlPrefix = customUrl.replace(/\/+$/, '')
|
||||
const cancelTask = [false]
|
||||
ipcMain.on(cancelDownloadLoadingFileList, (_evt: IpcMainEvent, token: string) => {
|
||||
if (token === cancelToken) {
|
||||
cancelTask[0] = true
|
||||
ipcMain.removeAllListeners(cancelDownloadLoadingFileList)
|
||||
}
|
||||
})
|
||||
let res = {} as any
|
||||
const result = {
|
||||
fullList: <any>[],
|
||||
success: false,
|
||||
finished: false
|
||||
}
|
||||
try {
|
||||
res = fsWalk.walkSync(prefix, {
|
||||
followSymbolicLinks: true,
|
||||
fs,
|
||||
stats: true,
|
||||
throwErrorOnBrokenSymbolicLink: false
|
||||
})
|
||||
if (res.length) {
|
||||
result.fullList.push(
|
||||
...res.data
|
||||
.filter((item: fsWalk.Entry) => item.stats?.isFile())
|
||||
.map((item: any) => this.formatFile(item, urlPrefix, item.name, item.path))
|
||||
)
|
||||
result.success = true
|
||||
}
|
||||
} catch (error) {
|
||||
this.logParam(error, 'getBucketListRecursively')
|
||||
}
|
||||
result.finished = true
|
||||
window.webContents.send(refreshDownloadFileTransferList, result)
|
||||
ipcMain.removeAllListeners(cancelDownloadLoadingFileList)
|
||||
}
|
||||
|
||||
async getBucketListBackstage (configMap: IStringKeyMap): Promise<any> {
|
||||
const window = windowManager.get(IWindowList.SETTING_WINDOW)!
|
||||
const { customUrl = '', cancelToken, baseDir } = configMap
|
||||
let prefix = configMap.prefix
|
||||
prefix = this.transBack(prefix)
|
||||
let urlPrefix = customUrl.replace(/\/+$/, '')
|
||||
let webPath = configMap.webPath || ''
|
||||
if (webPath && customUrl && webPath !== '/') {
|
||||
webPath = webPath.replace(/^\/+|\/+$/, '')
|
||||
}
|
||||
|
||||
const cancelTask = [false]
|
||||
ipcMain.on('cancelLoadingFileList', (_evt: IpcMainEvent, token: string) => {
|
||||
if (token === cancelToken) {
|
||||
cancelTask[0] = true
|
||||
ipcMain.removeAllListeners('cancelLoadingFileList')
|
||||
}
|
||||
})
|
||||
const result = {
|
||||
fullList: <any>[],
|
||||
success: false,
|
||||
finished: false
|
||||
}
|
||||
try {
|
||||
const res = await fs.readdir(prefix, {
|
||||
withFileTypes: true
|
||||
})
|
||||
if (res.length) {
|
||||
res.forEach((item: fs.Dirent) => {
|
||||
const pathOfFile = path.join(prefix, item.name)
|
||||
const relativePath = path.relative(baseDir, pathOfFile)
|
||||
const relative = webPath && urlPrefix + `/${path.join(webPath, relativePath)}`.replace(/\\/g, '/').replace(/\/+/g, '/')
|
||||
if (webPath && customUrl) {
|
||||
urlPrefix = relative
|
||||
} else {
|
||||
urlPrefix = pathOfFile
|
||||
}
|
||||
const stats = fs.statSync(pathOfFile)
|
||||
if (item.isDirectory()) {
|
||||
result.fullList.push(this.formatFolder(stats, urlPrefix, item.name, relativePath))
|
||||
} else {
|
||||
result.fullList.push(this.formatFile(stats, urlPrefix, item.name, relativePath))
|
||||
}
|
||||
})
|
||||
result.success = true
|
||||
}
|
||||
} catch (error) {
|
||||
this.logParam(error, 'getBucketListBackstage')
|
||||
}
|
||||
result.finished = true
|
||||
window.webContents.send('refreshFileTransferList', result)
|
||||
ipcMain.removeAllListeners('cancelLoadingFileList')
|
||||
}
|
||||
|
||||
async renameBucketFile (configMap: IStringKeyMap): Promise<boolean> {
|
||||
const { oldKey, newKey } = configMap
|
||||
let result = false
|
||||
try {
|
||||
await fs.rename(this.transBack(oldKey), this.transBack(newKey))
|
||||
result = true
|
||||
} catch (error) {
|
||||
this.logParam(error, 'renameBucketFile')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteBucketFile (configMap: IStringKeyMap): Promise<boolean> {
|
||||
const { key } = configMap
|
||||
let result = false
|
||||
try {
|
||||
await fs.remove(this.transBack(key))
|
||||
result = true
|
||||
} catch (error) {
|
||||
this.logParam(error, 'deleteBucketFile')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteBucketFolder (configMap: IStringKeyMap): Promise<boolean> {
|
||||
const { key } = configMap
|
||||
let result = false
|
||||
try {
|
||||
await fs.rmdir(this.transBack(key), {
|
||||
recursive: true
|
||||
})
|
||||
result = true
|
||||
} catch (error) {
|
||||
this.logParam(error, 'deleteBucketFolder')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async uploadBucketFile (configMap: IStringKeyMap): Promise<boolean> {
|
||||
const { fileArray } = configMap
|
||||
const instance = UpDownTaskQueue.getInstance()
|
||||
for (const item of fileArray) {
|
||||
const { alias, bucketName, key, filePath, fileName } = item
|
||||
const id = `${alias}-${bucketName}-${key}-${filePath}`
|
||||
if (instance.getUploadTask(id)) {
|
||||
continue
|
||||
}
|
||||
instance.addUploadTask({
|
||||
id,
|
||||
progress: 0,
|
||||
status: commonTaskStatus.queuing,
|
||||
sourceFileName: fileName,
|
||||
sourceFilePath: filePath,
|
||||
targetFilePath: key,
|
||||
targetFileBucket: bucketName,
|
||||
targetFileRegion: '',
|
||||
noProgress: true
|
||||
})
|
||||
try {
|
||||
fs.ensureFileSync(filePath)
|
||||
await fs.copyFile(filePath, this.transBack(key))
|
||||
instance.updateUploadTask({
|
||||
id,
|
||||
progress: 100,
|
||||
status: uploadTaskSpecialStatus.uploaded,
|
||||
finishTime: new Date().toLocaleString()
|
||||
})
|
||||
} catch (error) {
|
||||
this.logParam(error, 'uploadBucketFile')
|
||||
instance.updateUploadTask({
|
||||
id,
|
||||
progress: 0,
|
||||
status: commonTaskStatus.failed,
|
||||
finishTime: new Date().toLocaleString()
|
||||
})
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async createBucketFolder (configMap: IStringKeyMap): Promise<boolean> {
|
||||
const { key } = configMap
|
||||
let result = false
|
||||
try {
|
||||
await fs.mkdir(this.transBack(key), {
|
||||
recursive: true
|
||||
})
|
||||
result = true
|
||||
} catch (error) {
|
||||
this.logParam(error, 'createBucketFolder')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async downloadBucketFile (configMap: IStringKeyMap): Promise<boolean> {
|
||||
const { downloadPath, fileArray } = configMap
|
||||
const instance = UpDownTaskQueue.getInstance()
|
||||
for (const item of fileArray) {
|
||||
const { alias, bucketName, key, fileName } = item
|
||||
const savedFilePath = path.join(downloadPath, fileName)
|
||||
const id = `${alias}-${bucketName}-local-${key}`
|
||||
if (instance.getDownloadTask(id)) {
|
||||
continue
|
||||
}
|
||||
instance.addDownloadTask({
|
||||
id,
|
||||
progress: 0,
|
||||
status: commonTaskStatus.queuing,
|
||||
sourceFileName: fileName,
|
||||
targetFilePath: savedFilePath
|
||||
})
|
||||
try {
|
||||
fs.ensureFileSync(savedFilePath)
|
||||
await fs.copyFile(this.transBack(key), savedFilePath)
|
||||
instance.updateDownloadTask({
|
||||
id,
|
||||
progress: 100,
|
||||
status: downloadTaskSpecialStatus.downloaded,
|
||||
finishTime: new Date().toLocaleString()
|
||||
})
|
||||
} catch (error) {
|
||||
this.logParam(error, 'downloadBucketFile')
|
||||
instance.updateDownloadTask({
|
||||
id,
|
||||
progress: 0,
|
||||
status: commonTaskStatus.failed,
|
||||
finishTime: new Date().toLocaleString()
|
||||
})
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalApi
|
||||
|
||||
@@ -137,20 +137,11 @@ class WebdavplistApi {
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
result.finished = true
|
||||
window.webContents.send(refreshDownloadFileTransferList, result)
|
||||
ipcMain.removeAllListeners(cancelDownloadLoadingFileList)
|
||||
return
|
||||
result.success = true
|
||||
}
|
||||
} catch (error) {
|
||||
this.logParam(error, 'getBucketListRecursively')
|
||||
result.finished = true
|
||||
window.webContents.send(refreshDownloadFileTransferList, result)
|
||||
ipcMain.removeAllListeners(cancelDownloadLoadingFileList)
|
||||
return
|
||||
}
|
||||
result.success = true
|
||||
result.finished = true
|
||||
window.webContents.send(refreshDownloadFileTransferList, result)
|
||||
ipcMain.removeAllListeners(cancelDownloadLoadingFileList)
|
||||
|
||||
@@ -54,22 +54,24 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
createClient () {
|
||||
const name = this.currentPicBedConfig.picBedName
|
||||
switch (name) {
|
||||
case 'tcyun':
|
||||
return new API.TcyunApi(this.currentPicBedConfig.secretId, this.currentPicBedConfig.secretKey, this.logger)
|
||||
case 'aliyun':
|
||||
return new API.AliyunApi(this.currentPicBedConfig.accessKeyId, this.currentPicBedConfig.accessKeySecret, this.logger)
|
||||
case 'qiniu':
|
||||
return new API.QiniuApi(this.currentPicBedConfig.accessKey, this.currentPicBedConfig.secretKey, this.logger)
|
||||
case 'upyun':
|
||||
return new API.UpyunApi(this.currentPicBedConfig.bucketName, this.currentPicBedConfig.operator, this.currentPicBedConfig.password, this.logger)
|
||||
case 'smms':
|
||||
return new API.SmmsApi(this.currentPicBedConfig.token, this.logger)
|
||||
case 'github':
|
||||
return new API.GithubApi(this.currentPicBedConfig.token, this.currentPicBedConfig.githubUsername, this.currentPicBedConfig.proxy, this.logger)
|
||||
case 'imgur':
|
||||
return new API.ImgurApi(this.currentPicBedConfig.imgurUserName, this.currentPicBedConfig.accessToken, this.currentPicBedConfig.proxy, this.logger)
|
||||
case 'local':
|
||||
return new API.LocalApi(this.logger)
|
||||
case 'qiniu':
|
||||
return new API.QiniuApi(this.currentPicBedConfig.accessKey, this.currentPicBedConfig.secretKey, this.logger)
|
||||
case 'smms':
|
||||
return new API.SmmsApi(this.currentPicBedConfig.token, this.logger)
|
||||
case 's3plist':
|
||||
return new API.S3plistApi(this.currentPicBedConfig.accessKeyId, this.currentPicBedConfig.secretAccessKey, this.currentPicBedConfig.endpoint, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.s3ForcePathStyle, this.currentPicBedConfig.proxy, this.logger)
|
||||
case 'tcyun':
|
||||
return new API.TcyunApi(this.currentPicBedConfig.secretId, this.currentPicBedConfig.secretKey, this.logger)
|
||||
case 'upyun':
|
||||
return new API.UpyunApi(this.currentPicBedConfig.bucketName, this.currentPicBedConfig.operator, this.currentPicBedConfig.password, this.logger)
|
||||
case 'webdavplist':
|
||||
return new API.WebdavplistApi(this.currentPicBedConfig.endpoint, this.currentPicBedConfig.username, this.currentPicBedConfig.password, this.currentPicBedConfig.sslEnabled, this.currentPicBedConfig.proxy, this.logger)
|
||||
default:
|
||||
@@ -148,6 +150,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
param?: IStringKeyMap | undefined
|
||||
): Promise<any> {
|
||||
let client
|
||||
const name = this.currentPicBedConfig.picBedName.replace('plist', '')
|
||||
switch (this.currentPicBedConfig.picBedName) {
|
||||
case 'tcyun':
|
||||
case 'aliyun':
|
||||
@@ -169,15 +172,11 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
CreationDate: new Date().toISOString()
|
||||
}]
|
||||
case 'smms':
|
||||
return [{
|
||||
Name: 'smms',
|
||||
Location: 'smms',
|
||||
CreationDate: new Date().toISOString()
|
||||
}]
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
return [{
|
||||
Name: 'webdav',
|
||||
Location: 'webdav',
|
||||
Name: name,
|
||||
Location: name,
|
||||
CreationDate: new Date().toISOString()
|
||||
}]
|
||||
default:
|
||||
@@ -313,6 +312,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'imgur':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
return await client.getBucketListRecursively(param!)
|
||||
@@ -356,6 +356,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'imgur':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
return await client.getBucketListBackstage(param!)
|
||||
@@ -426,6 +427,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'imgur':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
const res = await client.deleteBucketFile(param!)
|
||||
@@ -451,6 +453,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'github':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
return await client.deleteBucketFolder(param!)
|
||||
@@ -474,6 +477,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'upyun':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
return await client.renameBucketFile(param!)
|
||||
@@ -500,6 +504,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'imgur':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
const res = await client.downloadBucketFile(param!)
|
||||
@@ -532,6 +537,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'github':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
return await client.createBucketFolder(param!)
|
||||
@@ -558,6 +564,7 @@ export class ManageApi extends EventEmitter implements ManageApiType {
|
||||
case 'imgur':
|
||||
case 's3plist':
|
||||
case 'webdavplist':
|
||||
case 'local':
|
||||
try {
|
||||
client = this.createClient() as any
|
||||
return await client.uploadBucketFile(param!)
|
||||
|
||||
@@ -21,14 +21,11 @@ function beforeOpen () {
|
||||
*/
|
||||
function resolveMacWorkFlow () {
|
||||
const dest = `${os.homedir()}/Library/Services/Upload pictures with PicList.workflow`
|
||||
if (fs.existsSync(dest)) {
|
||||
return true
|
||||
} else {
|
||||
try {
|
||||
fs.copySync(path.join(__static, 'Upload pictures with PicList.workflow'), dest)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
if (fs.existsSync(dest)) return true
|
||||
try {
|
||||
fs.copySync(path.join(__static, 'Upload pictures with PicList.workflow'), dest)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user