Feature(custom): add upload max concurrency and upload interval setting for api

ISSUES CLOSED: #487
This commit is contained in:
Kuingsmile
2026-05-04 19:39:47 +08:00
parent 130a3904e6
commit 2441360cc6
8 changed files with 193 additions and 110 deletions

View File

@@ -22,6 +22,41 @@ const LOG_PATH = appLogPath()
const errorMessage = `upload error. see ${LOG_PATH} for more detail.` const errorMessage = `upload error. see ${LOG_PATH} for more detail.`
const deleteErrorMessage = `delete error. see ${LOG_PATH} for more detail.` const deleteErrorMessage = `delete error. see ${LOG_PATH} for more detail.`
// Upload rate-limiting state
let runningUploads = 0
const uploadWaitQueue: (() => void)[] = []
let lastUploadFinishTime = 0
async function withUploadRateLimit<T>(fn: () => Promise<T>): Promise<T> {
const allConfig = picgo.getConfig<any>() || {}
const maxConcurrency: number = allConfig.settings?.serverMaxConcurrency || 0
const uploadInterval: number = allConfig.settings?.serverUploadInterval || 0
if (maxConcurrency > 0) {
if (runningUploads >= maxConcurrency) {
await new Promise<void>(resolve => uploadWaitQueue.push(resolve))
}
runningUploads++
}
if (uploadInterval > 0) {
const wait = uploadInterval - (Date.now() - lastUploadFinishTime)
if (wait > 0) {
await new Promise(resolve => setTimeout(resolve, wait))
}
}
try {
return await fn()
} finally {
if (maxConcurrency > 0) {
runningUploads--
const next = uploadWaitQueue.shift()
if (next) next()
}
lastUploadFinishTime = Date.now()
}
}
async function responseForGet({ response }: { response: http.ServerResponse }) { async function responseForGet({ response }: { response: http.ServerResponse }) {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
const htmlContent = marked(markdownContent) const htmlContent = marked(markdownContent)
@@ -44,124 +79,126 @@ router.post(
urlparams?: URLSearchParams urlparams?: URLSearchParams
}): Promise<void> => { }): Promise<void> => {
try { try {
const allConfig = picgo.getConfig<any>() || {} await withUploadRateLimit(async () => {
const picbed = urlparams?.get('picbed') const allConfig = picgo.getConfig<any>() || {}
const passedKey = urlparams?.get('key') const picbed = urlparams?.get('picbed')
const serverKey = allConfig.settings?.serverKey || '' const passedKey = urlparams?.get('key')
const useShortUrl = allConfig.settings?.useShortUrl const serverKey = allConfig.settings?.serverKey || ''
if (serverKey && passedKey !== serverKey) { const useShortUrl = allConfig.settings?.useShortUrl
handleResponse({ if (serverKey && passedKey !== serverKey) {
response, handleResponse({
body: { response,
success: false, body: {
message: 'server key is uncorrect', success: false,
}, message: 'server key is uncorrect',
}) },
return })
} return
let currentPicBedType = '' }
let currentPicBedConfig = {} as IStringKeyMap let currentPicBedType = ''
let currentPicBedConfigId = '' let currentPicBedConfig = {} as IStringKeyMap
let needRestore = false let currentPicBedConfigId = ''
if (picbed) { let needRestore = false
const currentPicBed = allConfig.picBed || ({} as IStringKeyMap) if (picbed) {
currentPicBedType = currentPicBed.uploader || currentPicBed.current || 'smms' const currentPicBed = allConfig.picBed || ({} as IStringKeyMap)
currentPicBedConfig = currentPicBed[currentPicBedType] || ({} as IStringKeyMap) currentPicBedType = currentPicBed.uploader || currentPicBed.current || 'smms'
currentPicBedConfigId = currentPicBedConfig._id currentPicBedConfig = currentPicBed[currentPicBedType] || ({} as IStringKeyMap)
const configName = urlparams?.get('configName') || currentPicBed[picbed]?._configName currentPicBedConfigId = currentPicBedConfig._id
if (picbed === currentPicBedType && configName === currentPicBedConfig._configName) { const configName = urlparams?.get('configName') || currentPicBed[picbed]?._configName
// do nothing if (picbed === currentPicBedType && configName === currentPicBedConfig._configName) {
} else { // do nothing
needRestore = true } else {
const picBeds = allConfig.uploader needRestore = true
const currentPicBedList = picBeds?.[picbed]?.configList const picBeds = allConfig.uploader
if (currentPicBedList) { const currentPicBedList = picBeds?.[picbed]?.configList
const currentConfig = currentPicBedList?.find((item: any) => item._configName === configName) if (currentPicBedList) {
if (currentConfig) { const currentConfig = currentPicBedList?.find((item: any) => item._configName === configName)
changeCurrentUploader(picbed, currentConfig, currentConfig._id) if (currentConfig) {
changeCurrentUploader(picbed, currentConfig, currentConfig._id)
}
} }
} }
} }
} if (list.length === 0) {
if (list.length === 0) { // upload with clipboard
// upload with clipboard logger.info('[PicList Server] upload clipboard file')
logger.info('[PicList Server] upload clipboard file') const result = await uploadClipboardFiles()
const result = await uploadClipboardFiles() const res = useShortUrl ? result.fullResult.shortUrl || result.url : result.url
const res = useShortUrl ? result.fullResult.shortUrl || result.url : result.url const fullResult = result.fullResult
const fullResult = result.fullResult fullResult.imgUrl = useShortUrl ? fullResult.shortUrl || fullResult.imgUrl : fullResult.imgUrl
fullResult.imgUrl = useShortUrl ? fullResult.shortUrl || fullResult.imgUrl : fullResult.imgUrl logger.info('[PicList Server] upload result:', res)
logger.info('[PicList Server] upload result:', res) if (res) {
if (res) { const treatedFullResult = {
const treatedFullResult = { isEncrypted: 1,
isEncrypted: 1, EncryptedData: new AESHelper().encrypt(JSON.stringify(fullResult)),
EncryptedData: new AESHelper().encrypt(JSON.stringify(fullResult)), ...fullResult,
...fullResult, }
delete treatedFullResult.config
handleResponse({
response,
body: {
success: true,
result: [res],
fullResult: [treatedFullResult],
},
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage,
},
})
} }
delete treatedFullResult.config
handleResponse({
response,
body: {
success: true,
result: [res],
fullResult: [treatedFullResult],
},
})
} else { } else {
handleResponse({ logger.info('[PicList Server] upload files in list')
response, // upload with files
body: { const pathList = list.map(item => {
success: false, return {
message: errorMessage, path: item,
}, }
}) })
} const win = windowManager.getAvailableWindow()
} else { const result = await uploadChoosedFiles(win?.webContents, pathList)
logger.info('[PicList Server] upload files in list') const res = result.map(item => {
// upload with files return useShortUrl ? item.fullResult.shortUrl || item.url : item.url
const pathList = list.map(item => { })
return { const fullResult = result.map((item: any) => {
path: item, const treatedItem = {
isEncrypted: 1,
EncryptedData: new AESHelper().encrypt(JSON.stringify(item.fullResult)),
...item.fullResult,
}
delete treatedItem.config
treatedItem.imgUrl = useShortUrl ? treatedItem.shortUrl || treatedItem.imgUrl : treatedItem.imgUrl
return treatedItem
})
logger.info('[PicList Server] upload result', res.join(' ; '))
if (res.length) {
handleResponse({
response,
body: {
success: true,
result: res,
fullResult,
},
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage,
},
})
} }
})
const win = windowManager.getAvailableWindow()
const result = await uploadChoosedFiles(win?.webContents, pathList)
const res = result.map(item => {
return useShortUrl ? item.fullResult.shortUrl || item.url : item.url
})
const fullResult = result.map((item: any) => {
const treatedItem = {
isEncrypted: 1,
EncryptedData: new AESHelper().encrypt(JSON.stringify(item.fullResult)),
...item.fullResult,
}
delete treatedItem.config
treatedItem.imgUrl = useShortUrl ? treatedItem.shortUrl || treatedItem.imgUrl : treatedItem.imgUrl
return treatedItem
})
logger.info('[PicList Server] upload result', res.join(' ; '))
if (res.length) {
handleResponse({
response,
body: {
success: true,
result: res,
fullResult,
},
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage,
},
})
} }
} fs.emptyDirSync(serverTempDir)
fs.emptyDirSync(serverTempDir) if (needRestore) {
if (needRestore) { changeCurrentUploader(currentPicBedType, currentPicBedConfig, currentPicBedConfigId)
changeCurrentUploader(currentPicBedType, currentPicBedConfig, currentPicBedConfigId) }
} })
} catch (err: any) { } catch (err: any) {
logger.error(err) logger.error(err)
handleResponse({ handleResponse({

View File

@@ -51,6 +51,8 @@ export interface IConfigStruct {
deleteCloudFile: boolean deleteCloudFile: boolean
server: IServerConfig server: IServerConfig
serverKey: string serverKey: string
serverMaxConcurrency: number
serverUploadInterval: number
pasteStyle: string pasteStyle: string
aesPassword: string aesPassword: string
rename: boolean rename: boolean
@@ -146,6 +148,8 @@ export const configPaths = {
deleteCloudFile: 'settings.deleteCloudFile', deleteCloudFile: 'settings.deleteCloudFile',
server: 'settings.server', server: 'settings.server',
serverKey: 'settings.serverKey', serverKey: 'settings.serverKey',
serverMaxConcurrency: 'settings.serverMaxConcurrency',
serverUploadInterval: 'settings.serverUploadInterval',
pasteStyle: 'settings.pasteStyle', pasteStyle: 'settings.pasteStyle',
aesPassword: 'settings.aesPassword', aesPassword: 'settings.aesPassword',
rename: 'settings.rename', rename: 'settings.rename',

View File

@@ -854,9 +854,13 @@
"serverHost": "Listen Address", "serverHost": "Listen Address",
"serverKey": "Authentication Key", "serverKey": "Authentication Key",
"serverKeyPlaceholder": "Please enter the authentication key to prevent API abuse", "serverKeyPlaceholder": "Please enter the authentication key to prevent API abuse",
"serverMaxConcurrency": "Max Upload Concurrency",
"serverMaxConcurrencyPlaceholder": "Max simultaneous uploads via API (0 = unlimited)",
"serverPort": "Listen Port", "serverPort": "Listen Port",
"serverSettings": "Server Settings", "serverSettings": "Server Settings",
"serverSettingsNotice": "If you don't know the purpose of the Upload API Service, please read the documentation or do not modify the configuration.", "serverSettingsNotice": "If you don't know the purpose of the Upload API Service, please read the documentation or do not modify the configuration.",
"serverUploadInterval": "Upload Interval (ms)",
"serverUploadIntervalPlaceholder": "Delay in ms between uploads via API (0 = no delay)",
"setLog": "Set Log", "setLog": "Set Log",
"setLogDesc": "Configure log level and file size", "setLogDesc": "Configure log level and file size",
"setProxyAndMirror": "Set Proxy and Mirror Address", "setProxyAndMirror": "Set Proxy and Mirror Address",

View File

@@ -854,9 +854,13 @@
"serverHost": "监听地址", "serverHost": "监听地址",
"serverKey": "鉴权密钥", "serverKey": "鉴权密钥",
"serverKeyPlaceholder": "请输入鉴权密钥,用于防止接口滥用", "serverKeyPlaceholder": "请输入鉴权密钥,用于防止接口滥用",
"serverMaxConcurrency": "最大上传并发数",
"serverMaxConcurrencyPlaceholder": "通过API同时上传的最大数量0表示不限制",
"serverPort": "监听端口", "serverPort": "监听端口",
"serverSettings": "服务器设置", "serverSettings": "服务器设置",
"serverSettingsNotice": "如果你不知道上传API服务的作用请阅读文档或者不用修改配置。", "serverSettingsNotice": "如果你不知道上传API服务的作用请阅读文档或者不用修改配置。",
"serverUploadInterval": "上传间隔(毫秒)",
"serverUploadIntervalPlaceholder": "通过API上传时每次上传之间的延迟0表示无延迟",
"setLog": "设置日志", "setLog": "设置日志",
"setLogDesc": "配置日志等级和文件大小", "setLogDesc": "配置日志等级和文件大小",
"setProxyAndMirror": "设置代理和镜像地址", "setProxyAndMirror": "设置代理和镜像地址",

View File

@@ -854,9 +854,13 @@
"serverHost": "監聽地址", "serverHost": "監聽地址",
"serverKey": "鑑權密鑰", "serverKey": "鑑權密鑰",
"serverKeyPlaceholder": "請輸入鑑權密鑰,用於防止接口濫用", "serverKeyPlaceholder": "請輸入鑑權密鑰,用於防止接口濫用",
"serverMaxConcurrency": "最大上傳並發數",
"serverMaxConcurrencyPlaceholder": "通過API同時上傳的最大數量0表示不限制",
"serverPort": "監聽端口", "serverPort": "監聽端口",
"serverSettings": "伺服器設置", "serverSettings": "伺服器設置",
"serverSettingsNotice": "如果你不知道上傳API服務的作用請閱讀文檔或者不用修改配置。", "serverSettingsNotice": "如果你不知道上傳API服務的作用請閱讀文檔或者不用修改配置。",
"serverUploadInterval": "上傳間隔(毫秒)",
"serverUploadIntervalPlaceholder": "通過API上傳時每次上傳之間的延遲0表示無延遲",
"setLog": "設置日誌", "setLog": "設置日誌",
"setLogDesc": "配置日誌等級和文件大小", "setLogDesc": "配置日誌等級和文件大小",
"setProxyAndMirror": "設置代理和鏡像地址", "setProxyAndMirror": "設置代理和鏡像地址",

View File

@@ -1044,6 +1044,26 @@
:placeholder="t('pages.settings.advanced.serverKeyPlaceholder')" :placeholder="t('pages.settings.advanced.serverKeyPlaceholder')"
/> />
</SettingCard> </SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.serverMaxConcurrency"
type="number"
:min="0"
:step="1"
:title="t('pages.settings.advanced.serverMaxConcurrency')"
:placeholder="t('pages.settings.advanced.serverMaxConcurrencyPlaceholder')"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.serverUploadInterval"
type="number"
:min="0"
:step="100"
:title="t('pages.settings.advanced.serverUploadInterval')"
:placeholder="t('pages.settings.advanced.serverUploadIntervalPlaceholder')"
/>
</SettingCard>
</SettingSection> </SettingSection>
</div> </div>
<template #footer> <template #footer>
@@ -1441,6 +1461,8 @@ const formOfSetting = ref<ISettingForm>({
sinkToken: '', sinkToken: '',
deleteLocalFile: false, deleteLocalFile: false,
serverKey: '', serverKey: '',
serverMaxConcurrency: 0,
serverUploadInterval: 0,
aesPassword: 'PicList-aesPassword', aesPassword: 'PicList-aesPassword',
enableWebServer: false, enableWebServer: false,
webServerHost: '0.0.0.0', webServerHost: '0.0.0.0',
@@ -1552,6 +1574,8 @@ const autoWatchKeys = [
'webServerPort', 'webServerPort',
'webServerPath', 'webServerPath',
'serverKey', 'serverKey',
'serverMaxConcurrency',
'serverUploadInterval',
'uploadNotification', 'uploadNotification',
'uploadResultNotification', 'uploadResultNotification',
'autoCloseMainWindow', 'autoCloseMainWindow',

View File

@@ -51,6 +51,8 @@ export interface IConfigStruct {
deleteCloudFile: boolean deleteCloudFile: boolean
server: IServerConfig server: IServerConfig
serverKey: string serverKey: string
serverMaxConcurrency: number
serverUploadInterval: number
pasteStyle: string pasteStyle: string
aesPassword: string aesPassword: string
rename: boolean rename: boolean
@@ -151,6 +153,8 @@ export const configPaths = {
deleteCloudFile: 'settings.deleteCloudFile', deleteCloudFile: 'settings.deleteCloudFile',
server: 'settings.server', server: 'settings.server',
serverKey: 'settings.serverKey', serverKey: 'settings.serverKey',
serverMaxConcurrency: 'settings.serverMaxConcurrency',
serverUploadInterval: 'settings.serverUploadInterval',
pasteStyle: 'settings.pasteStyle', pasteStyle: 'settings.pasteStyle',
aesPassword: 'settings.aesPassword', aesPassword: 'settings.aesPassword',
rename: 'settings.rename', rename: 'settings.rename',

View File

@@ -30,6 +30,8 @@ interface ISettingForm {
sinkToken: string sinkToken: string
deleteLocalFile: boolean deleteLocalFile: boolean
serverKey: string serverKey: string
serverMaxConcurrency: number
serverUploadInterval: number
aesPassword: string aesPassword: string
enableWebServer: boolean enableWebServer: boolean
webServerHost: string webServerHost: string