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 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 }) {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
const htmlContent = marked(markdownContent)
@@ -44,124 +79,126 @@ router.post(
urlparams?: URLSearchParams
}): Promise<void> => {
try {
const allConfig = picgo.getConfig<any>() || {}
const picbed = urlparams?.get('picbed')
const passedKey = urlparams?.get('key')
const serverKey = allConfig.settings?.serverKey || ''
const useShortUrl = allConfig.settings?.useShortUrl
if (serverKey && passedKey !== serverKey) {
handleResponse({
response,
body: {
success: false,
message: 'server key is uncorrect',
},
})
return
}
let currentPicBedType = ''
let currentPicBedConfig = {} as IStringKeyMap
let currentPicBedConfigId = ''
let needRestore = false
if (picbed) {
const currentPicBed = allConfig.picBed || ({} as IStringKeyMap)
currentPicBedType = currentPicBed.uploader || currentPicBed.current || 'smms'
currentPicBedConfig = currentPicBed[currentPicBedType] || ({} as IStringKeyMap)
currentPicBedConfigId = currentPicBedConfig._id
const configName = urlparams?.get('configName') || currentPicBed[picbed]?._configName
if (picbed === currentPicBedType && configName === currentPicBedConfig._configName) {
// do nothing
} else {
needRestore = true
const picBeds = allConfig.uploader
const currentPicBedList = picBeds?.[picbed]?.configList
if (currentPicBedList) {
const currentConfig = currentPicBedList?.find((item: any) => item._configName === configName)
if (currentConfig) {
changeCurrentUploader(picbed, currentConfig, currentConfig._id)
await withUploadRateLimit(async () => {
const allConfig = picgo.getConfig<any>() || {}
const picbed = urlparams?.get('picbed')
const passedKey = urlparams?.get('key')
const serverKey = allConfig.settings?.serverKey || ''
const useShortUrl = allConfig.settings?.useShortUrl
if (serverKey && passedKey !== serverKey) {
handleResponse({
response,
body: {
success: false,
message: 'server key is uncorrect',
},
})
return
}
let currentPicBedType = ''
let currentPicBedConfig = {} as IStringKeyMap
let currentPicBedConfigId = ''
let needRestore = false
if (picbed) {
const currentPicBed = allConfig.picBed || ({} as IStringKeyMap)
currentPicBedType = currentPicBed.uploader || currentPicBed.current || 'smms'
currentPicBedConfig = currentPicBed[currentPicBedType] || ({} as IStringKeyMap)
currentPicBedConfigId = currentPicBedConfig._id
const configName = urlparams?.get('configName') || currentPicBed[picbed]?._configName
if (picbed === currentPicBedType && configName === currentPicBedConfig._configName) {
// do nothing
} else {
needRestore = true
const picBeds = allConfig.uploader
const currentPicBedList = picBeds?.[picbed]?.configList
if (currentPicBedList) {
const currentConfig = currentPicBedList?.find((item: any) => item._configName === configName)
if (currentConfig) {
changeCurrentUploader(picbed, currentConfig, currentConfig._id)
}
}
}
}
}
if (list.length === 0) {
// upload with clipboard
logger.info('[PicList Server] upload clipboard file')
const result = await uploadClipboardFiles()
const res = useShortUrl ? result.fullResult.shortUrl || result.url : result.url
const fullResult = result.fullResult
fullResult.imgUrl = useShortUrl ? fullResult.shortUrl || fullResult.imgUrl : fullResult.imgUrl
logger.info('[PicList Server] upload result:', res)
if (res) {
const treatedFullResult = {
isEncrypted: 1,
EncryptedData: new AESHelper().encrypt(JSON.stringify(fullResult)),
...fullResult,
if (list.length === 0) {
// upload with clipboard
logger.info('[PicList Server] upload clipboard file')
const result = await uploadClipboardFiles()
const res = useShortUrl ? result.fullResult.shortUrl || result.url : result.url
const fullResult = result.fullResult
fullResult.imgUrl = useShortUrl ? fullResult.shortUrl || fullResult.imgUrl : fullResult.imgUrl
logger.info('[PicList Server] upload result:', res)
if (res) {
const treatedFullResult = {
isEncrypted: 1,
EncryptedData: new AESHelper().encrypt(JSON.stringify(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 {
handleResponse({
response,
body: {
success: false,
message: errorMessage,
},
logger.info('[PicList Server] upload files in list')
// upload with files
const pathList = list.map(item => {
return {
path: item,
}
})
}
} else {
logger.info('[PicList Server] upload files in list')
// upload with files
const pathList = list.map(item => {
return {
path: item,
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,
},
})
}
})
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)
if (needRestore) {
changeCurrentUploader(currentPicBedType, currentPicBedConfig, currentPicBedConfigId)
}
fs.emptyDirSync(serverTempDir)
if (needRestore) {
changeCurrentUploader(currentPicBedType, currentPicBedConfig, currentPicBedConfigId)
}
})
} catch (err: any) {
logger.error(err)
handleResponse({

View File

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

View File

@@ -854,9 +854,13 @@
"serverHost": "Listen Address",
"serverKey": "Authentication Key",
"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",
"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.",
"serverUploadInterval": "Upload Interval (ms)",
"serverUploadIntervalPlaceholder": "Delay in ms between uploads via API (0 = no delay)",
"setLog": "Set Log",
"setLogDesc": "Configure log level and file size",
"setProxyAndMirror": "Set Proxy and Mirror Address",

View File

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

View File

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

View File

@@ -1044,6 +1044,26 @@
:placeholder="t('pages.settings.advanced.serverKeyPlaceholder')"
/>
</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>
</div>
<template #footer>
@@ -1441,6 +1461,8 @@ const formOfSetting = ref<ISettingForm>({
sinkToken: '',
deleteLocalFile: false,
serverKey: '',
serverMaxConcurrency: 0,
serverUploadInterval: 0,
aesPassword: 'PicList-aesPassword',
enableWebServer: false,
webServerHost: '0.0.0.0',
@@ -1552,6 +1574,8 @@ const autoWatchKeys = [
'webServerPort',
'webServerPath',
'serverKey',
'serverMaxConcurrency',
'serverUploadInterval',
'uploadNotification',
'uploadResultNotification',
'autoCloseMainWindow',

View File

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

View File

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