diff --git a/.env.example b/.env.example index 8f0b3c6..10c1add 100644 --- a/.env.example +++ b/.env.example @@ -132,7 +132,8 @@ TOTP_SECRET= # 默认只允许 HTTPS 的 S3/WebDAV endpoint。仅在可信内网测试时设为 true。 ALLOW_INSECURE_STORAGE_ENDPOINTS=false -# WebDAV 读请求无活动超时和单次上传总超时。 +# 云存储连接测试超时,以及 WebDAV 读请求无活动和单次上传超时。 +STORAGE_PROBE_TIMEOUT_MS=15000 WEBDAV_INACTIVITY_TIMEOUT_MS=300000 WEBDAV_UPLOAD_TIMEOUT_MS=21600000 diff --git a/backend/dist/index.js b/backend/dist/index.js index 15c7033..abacbb4 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -780,6 +780,21 @@ function storageProbeError(provider, error) { const { reason, code } = summarizeStorageProbeFailure(error); return new StorageProbeError(provider, `${provider} \u8FDE\u63A5\u6D4B\u8BD5\u5931\u8D25\uFF1A${reason}`, code); } +function configuredStorageProbeTimeoutMs() { + const configured2 = Number(process.env.STORAGE_PROBE_TIMEOUT_MS); + return Number.isFinite(configured2) && configured2 > 0 ? Math.max(1e3, configured2) : 15e3; +} +async function withStorageProbeDeadline(operation, provider, timeoutMs) { + let timeout; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`${provider} probe timed out after ${timeoutMs}ms`)), timeoutMs); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + clearTimeout(timeout); + } +} var StorageQuotaCooldownError, StorageProbeError, LocalStorageProvider, AliyunOSSStorageProvider, S3StorageProvider, WebDAVStorageProvider, OneDriveStorageProvider, GoogleDriveStorageProvider, StorageManager, storageManager; var init_storage = __esm({ "src/services/storage.ts"() { @@ -877,8 +892,9 @@ var init_storage = __esm({ } }; AliyunOSSStorageProvider = class { - constructor(id, region, accessKeyId, accessKeySecret, bucket) { + constructor(id, region, accessKeyId, accessKeySecret, bucket, probeTimeoutMs = configuredStorageProbeTimeoutMs()) { this.id = id; + this.probeTimeoutMs = probeTimeoutMs; const sanitizedRegion = this.sanitizeRegion(region); this.client = new OSS({ region: sanitizedRegion, @@ -889,6 +905,7 @@ var init_storage = __esm({ }); } id; + probeTimeoutMs; name = "aliyun_oss"; client; sanitizeRegion(region) { @@ -901,7 +918,11 @@ var init_storage = __esm({ } async probe() { try { - await this.client.list({ "max-keys": 1 }); + await withStorageProbeDeadline( + this.client.list({ "max-keys": 1 }, { timeout: this.probeTimeoutMs }), + "Aliyun OSS", + this.probeTimeoutMs + ); } catch (error) { throw storageProbeError(this.name, error); } @@ -955,7 +976,7 @@ var init_storage = __esm({ } }; S3StorageProvider = class { - constructor(id, endpoint, region, accessKeyId, secretAccessKey, bucket, forcePathStyle = false) { + constructor(id, endpoint, region, accessKeyId, secretAccessKey, bucket, forcePathStyle = false, probeTimeoutMs = configuredStorageProbeTimeoutMs()) { this.id = id; this.endpoint = endpoint; this.region = region; @@ -963,6 +984,7 @@ var init_storage = __esm({ this.secretAccessKey = secretAccessKey; this.bucket = bucket; this.forcePathStyle = forcePathStyle; + this.probeTimeoutMs = probeTimeoutMs; this.client = new S3Client({ endpoint, region, @@ -980,13 +1002,21 @@ var init_storage = __esm({ secretAccessKey; bucket; forcePathStyle; + probeTimeoutMs; name = "s3"; client; async probe() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error(`S3 probe timed out after ${this.probeTimeoutMs}ms`)), this.probeTimeoutMs); try { - await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, MaxKeys: 1 })); + await this.client.send( + new ListObjectsV2Command({ Bucket: this.bucket, MaxKeys: 1 }), + { abortSignal: controller.signal } + ); } catch (error) { - throw storageProbeError(this.name, error); + throw storageProbeError(this.name, controller.signal.aborted ? controller.signal.reason : error); + } finally { + clearTimeout(timeout); } } async saveFile(tempPath, fileName, mimeType, folder) { @@ -1065,6 +1095,8 @@ var init_storage = __esm({ })(), uploadTimeoutMs = (() => { const configured2 = Number(process.env.WEBDAV_UPLOAD_TIMEOUT_MS); return Number.isFinite(configured2) && configured2 > 0 ? Math.max(6e4, configured2) : 6 * 60 * 60 * 1e3; + })(), probeTimeoutMs = (() => { + return configuredStorageProbeTimeoutMs(); })()) { this.id = id; this.url = url; @@ -1072,6 +1104,7 @@ var init_storage = __esm({ this.password = password; this.requestTimeoutMs = requestTimeoutMs; this.uploadTimeoutMs = uploadTimeoutMs; + this.probeTimeoutMs = probeTimeoutMs; this.client = createClient(url, { username, password @@ -1083,6 +1116,7 @@ var init_storage = __esm({ password; requestTimeoutMs; uploadTimeoutMs; + probeTimeoutMs; name = "webdav"; client; async withRequestTimeout(operation, label, timeoutMs = this.requestTimeoutMs) { @@ -1108,7 +1142,8 @@ var init_storage = __esm({ try { await this.withRequestTimeout( (signal) => this.client.getDirectoryContents("/", { deep: false, signal }), - "WebDAV probe" + "WebDAV probe", + this.probeTimeoutMs ); } catch (error) { throw storageProbeError(this.name, error); @@ -17212,6 +17247,17 @@ function sendStorageOperationError(res, error, fallback) { } res.status(500).json({ error: fallback }); } +function sendStorageEndpointValidationError(res, error) { + const message = error instanceof Error ? error.message : ""; + const safeMessages = [ + "\u94FE\u63A5\u683C\u5F0F\u65E0\u6548", + "\u4EC5\u5141\u8BB8 http/https \u94FE\u63A5", + "\u4E0D\u5141\u8BB8\u8BBF\u95EE\u672C\u673A\u5730\u5740", + "\u4E0D\u5141\u8BB8\u8BBF\u95EE\u5185\u7F51\u3001\u56DE\u73AF\u6216\u4FDD\u7559\u5730\u5740", + "\u5B58\u50A8\u7AEF\u70B9\u4EC5\u5141\u8BB8 https\uFF1B\u5982\u786E\u9700 http\uFF0C\u8BF7\u663E\u5F0F\u8BBE\u7F6E ALLOW_INSECURE_STORAGE_ENDPOINTS=true" + ]; + res.status(400).json({ error: safeMessages.includes(message) ? message : "\u65E0\u6CD5\u89E3\u6790\u5B58\u50A8\u7AEF\u70B9\u5730\u5740" }); +} function sendOAuthSuccessPage(res, input) { const nonce = crypto20.randomBytes(16).toString("base64"); res.setHeader("Content-Security-Policy", [ @@ -17674,7 +17720,11 @@ router5.post("/config/s3", requireAuth, async (req, res) => { if (!name || !endpoint || !region || !accessKeyId || !accessKeySecret || !bucket) { return res.status(400).json({ error: "\u7F3A\u5C11\u5FC5\u8981\u53C2\u6570" }); } - await assertPublicStorageEndpoint(endpoint); + try { + await assertPublicStorageEndpoint(endpoint); + } catch (error) { + return sendStorageEndpointValidationError(res, error); + } const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); const accountId = await storageManager2.addS3Account(name, endpoint, region, accessKeyId, accessKeySecret, bucket, forcePathStyle || false); res.json({ success: true, message: "S3 \u5B58\u50A8\u8D26\u6237\u5DF2\u6DFB\u52A0", accountId }); @@ -17689,7 +17739,11 @@ router5.post("/config/webdav", requireAuth, async (req, res) => { if (!name || !url) { return res.status(400).json({ error: "\u7F3A\u5C11\u5FC5\u8981\u53C2\u6570 (\u540D\u79F0\u548C URL)" }); } - await assertPublicStorageEndpoint(url); + try { + await assertPublicStorageEndpoint(url); + } catch (error) { + return sendStorageEndpointValidationError(res, error); + } const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); const accountId = await storageManager2.addWebDAVAccount(name, url, username, password); res.json({ success: true, message: "WebDAV \u5B58\u50A8\u8D26\u6237\u5DF2\u6DFB\u52A0", accountId }); @@ -19508,6 +19562,7 @@ var NUMBER_SPECS = [ { name: "TG_DISK_WATERMARK_RECHECK_MS", fallback: 3e4, min: 5e3, max: 36e5 }, { name: "TG_DISK_WATERMARK_MAX_WAIT_MS", fallback: 0, min: 0, max: 6048e5 }, { name: "TG_DEBUG_LOG_MAX_MB", fallback: 5, min: 1, max: 1024 }, + { name: "STORAGE_PROBE_TIMEOUT_MS", fallback: 15e3, min: 1e3, max: 6e4 }, { name: "WEBDAV_INACTIVITY_TIMEOUT_MS", fallback: 3e5, min: 3e4, max: 864e5 }, { name: "WEBDAV_UPLOAD_TIMEOUT_MS", fallback: 216e5, min: 6e4, max: 6048e5 }, { name: "YTDLP_MAX_CONCURRENT", fallback: 1, min: 1, max: 16 } @@ -19612,6 +19667,7 @@ function validateRuntimeConfig(env = process.env) { workDir: env.YTDLP_WORK_DIR || "./data/uploads/ytdlp" }, storage: { + probeTimeoutMs: numbers.STORAGE_PROBE_TIMEOUT_MS, webdavInactivityTimeoutMs: numbers.WEBDAV_INACTIVITY_TIMEOUT_MS, webdavUploadTimeoutMs: numbers.WEBDAV_UPLOAD_TIMEOUT_MS, allowInsecureEndpoints diff --git a/backend/src/routes/storage.ts b/backend/src/routes/storage.ts index 82576ef..20a5aa2 100644 --- a/backend/src/routes/storage.ts +++ b/backend/src/routes/storage.ts @@ -46,6 +46,18 @@ function sendStorageOperationError(res: Response, error: unknown, fallback: stri res.status(500).json({ error: fallback }); } +function sendStorageEndpointValidationError(res: Response, error: unknown): void { + const message = error instanceof Error ? error.message : ''; + const safeMessages = [ + '链接格式无效', + '仅允许 http/https 链接', + '不允许访问本机地址', + '不允许访问内网、回环或保留地址', + '存储端点仅允许 https;如确需 http,请显式设置 ALLOW_INSECURE_STORAGE_ENDPOINTS=true', + ]; + res.status(400).json({ error: safeMessages.includes(message) ? message : '无法解析存储端点地址' }); +} + function sendOAuthSuccessPage(res: Response, input: { provider: OAuthProvider; providerName: string; @@ -560,7 +572,11 @@ router.post('/config/s3', requireAuth, async (req: Request, res: Response) => { return res.status(400).json({ error: '缺少必要参数' }); } - await assertPublicStorageEndpoint(endpoint); + try { + await assertPublicStorageEndpoint(endpoint); + } catch (error) { + return sendStorageEndpointValidationError(res, error); + } const { storageManager } = await import('../services/storage.js'); const accountId = await storageManager.addS3Account(name, endpoint, region, accessKeyId, accessKeySecret, bucket, forcePathStyle || false); @@ -581,7 +597,11 @@ router.post('/config/webdav', requireAuth, async (req: Request, res: Response) = return res.status(400).json({ error: '缺少必要参数 (名称和 URL)' }); } - await assertPublicStorageEndpoint(url); + try { + await assertPublicStorageEndpoint(url); + } catch (error) { + return sendStorageEndpointValidationError(res, error); + } const { storageManager } = await import('../services/storage.js'); const accountId = await storageManager.addWebDAVAccount(name, url, username, password); diff --git a/backend/src/services/cloudStorageProbeTimeout.test.ts b/backend/src/services/cloudStorageProbeTimeout.test.ts new file mode 100644 index 0000000..82012de --- /dev/null +++ b/backend/src/services/cloudStorageProbeTimeout.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { AliyunOSSStorageProvider, S3StorageProvider } from './storage.js'; + +test('Aliyun OSS connection probe passes the short probe timeout to the SDK', async () => { + const provider = new AliyunOSSStorageProvider('probe', 'oss-cn-hangzhou', 'key', 'secret', 'bucket-name', 20); + let receivedTimeout: number | undefined; + (provider as any).client = { + list: async (_query: unknown, options?: { timeout?: number }) => { + receivedTimeout = options?.timeout; + await new Promise(() => undefined); + }, + }; + + await assert.rejects(() => provider.probe(), /aliyun_oss.*连接测试失败.*连接测试超时/i); + assert.equal(receivedTimeout, 20); +}); + +test('S3 connection probe aborts a stalled SDK request at the short deadline', async () => { + const provider = new S3StorageProvider('probe', 'https://s3.example.com', 'us-east-1', 'key', 'secret', 'bucket-name', false, 20); + let signal: AbortSignal | undefined; + (provider as any).client = { + send: async (_command: unknown, options?: { abortSignal?: AbortSignal }) => { + signal = options?.abortSignal; + await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal?.reason || new Error('aborted')), { once: true }); + }); + }, + }; + + await assert.rejects(() => provider.probe(), /s3.*连接测试失败.*连接测试超时/i); + assert.equal(signal?.aborted, true); +}); diff --git a/backend/src/services/storage.ts b/backend/src/services/storage.ts index bcc3dab..ecf2bbd 100644 --- a/backend/src/services/storage.ts +++ b/backend/src/services/storage.ts @@ -142,6 +142,23 @@ function storageProbeError(provider: string, error: unknown): StorageProbeError return new StorageProbeError(provider, `${provider} 连接测试失败:${reason}`, code); } +function configuredStorageProbeTimeoutMs(): number { + const configured = Number(process.env.STORAGE_PROBE_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 ? Math.max(1_000, configured) : 15_000; +} + +async function withStorageProbeDeadline(operation: Promise, provider: string, timeoutMs: number): Promise { + let timeout: ReturnType; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`${provider} probe timed out after ${timeoutMs}ms`)), timeoutMs); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + clearTimeout(timeout!); + } +} + // 本地存储实现 export class LocalStorageProvider implements IStorageProvider { name = 'local'; @@ -226,7 +243,8 @@ export class AliyunOSSStorageProvider implements IStorageProvider { region: string, accessKeyId: string, accessKeySecret: string, - bucket: string + bucket: string, + private probeTimeoutMs = configuredStorageProbeTimeoutMs(), ) { const sanitizedRegion = this.sanitizeRegion(region); this.client = new OSS({ @@ -253,7 +271,11 @@ export class AliyunOSSStorageProvider implements IStorageProvider { async probe(): Promise { try { - await this.client.list({ 'max-keys': 1 }); + await withStorageProbeDeadline( + this.client.list({ 'max-keys': 1 }, { timeout: this.probeTimeoutMs }), + 'Aliyun OSS', + this.probeTimeoutMs, + ); } catch (error) { throw storageProbeError(this.name, error); } @@ -325,7 +347,8 @@ export class S3StorageProvider implements IStorageProvider { private accessKeyId: string, private secretAccessKey: string, private bucket: string, - private forcePathStyle: boolean = false + private forcePathStyle: boolean = false, + private probeTimeoutMs = configuredStorageProbeTimeoutMs(), ) { this.client = new S3Client({ endpoint: endpoint, @@ -339,10 +362,17 @@ export class S3StorageProvider implements IStorageProvider { } async probe(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new Error(`S3 probe timed out after ${this.probeTimeoutMs}ms`)), this.probeTimeoutMs); try { - await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, MaxKeys: 1 })); + await this.client.send( + new ListObjectsV2Command({ Bucket: this.bucket, MaxKeys: 1 }), + { abortSignal: controller.signal }, + ); } catch (error) { - throw storageProbeError(this.name, error); + throw storageProbeError(this.name, controller.signal.aborted ? controller.signal.reason : error); + } finally { + clearTimeout(timeout); } } @@ -440,6 +470,9 @@ export class WebDAVStorageProvider implements IStorageProvider { const configured = Number(process.env.WEBDAV_UPLOAD_TIMEOUT_MS); return Number.isFinite(configured) && configured > 0 ? Math.max(60_000, configured) : 6 * 60 * 60 * 1000; })(), + private probeTimeoutMs = (() => { + return configuredStorageProbeTimeoutMs(); + })(), ) { this.client = createClient(url, { username: username, @@ -476,6 +509,7 @@ export class WebDAVStorageProvider implements IStorageProvider { await this.withRequestTimeout( signal => this.client.getDirectoryContents('/', { deep: false, signal }), 'WebDAV probe', + this.probeTimeoutMs, ); } catch (error) { throw storageProbeError(this.name, error); diff --git a/backend/src/services/webdavTimeout.test.ts b/backend/src/services/webdavTimeout.test.ts index 1288464..e51268c 100644 --- a/backend/src/services/webdavTimeout.test.ts +++ b/backend/src/services/webdavTimeout.test.ts @@ -61,6 +61,22 @@ test('WebDAV metadata request aborts when the remote stops responding', async () await assert.rejects(() => provider.getFileSize('remote/object'), /WebDAV stat timed out after 20ms/); }); +test('WebDAV connection probe uses its own short deadline', async () => { + const provider = new WebDAVStorageProvider('account-1', 'http://127.0.0.1:9', undefined, undefined, 500, 500, 20); + let signal: AbortSignal | undefined; + (provider as any).client = { + getDirectoryContents: async (_remotePath: string, options?: { signal?: AbortSignal }) => { + signal = options?.signal; + await new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason || new Error('aborted')), { once: true }); + }); + }, + }; + + await assert.rejects(() => provider.probe(), /webdav.*连接测试失败.*连接测试超时/i); + assert.equal(signal?.aborted, true); +}); + test('WebDAV delete aborts a stalled request instead of hanging the API forever', async () => { const provider = new WebDAVStorageProvider('account-1', 'http://127.0.0.1:9', undefined, undefined, 20); (provider as any).client = { diff --git a/backend/src/utils/runtimeConfig.test.ts b/backend/src/utils/runtimeConfig.test.ts index c0441d9..850eb9a 100644 --- a/backend/src/utils/runtimeConfig.test.ts +++ b/backend/src/utils/runtimeConfig.test.ts @@ -7,6 +7,7 @@ function testDefaultsMatchRuntimeContract() { assert.equal(summary.upload.maxUploadGiB, 20); assert.equal(summary.telegram.enabled, false); assert.equal(summary.ytdlp.maxConcurrent, 1); + assert.equal(summary.storage.probeTimeoutMs, 15_000); } function testRejectsUnsafeRangesAndPartialTelegramConfig() { @@ -14,6 +15,10 @@ function testRejectsUnsafeRangesAndPartialTelegramConfig() { () => validateRuntimeConfig({ MAX_UPLOAD_CHUNK_MB: '0' }), /MAX_UPLOAD_CHUNK_MB/, ); + assert.throws( + () => validateRuntimeConfig({ STORAGE_PROBE_TIMEOUT_MS: '0' }), + /STORAGE_PROBE_TIMEOUT_MS/, + ); assert.throws( () => validateRuntimeConfig({ MAX_CHUNK_UPLOAD_GB: '50', CHUNK_GLOBAL_BUDGET_GB: '40' }), /CHUNK_GLOBAL_BUDGET_GB/, @@ -45,4 +50,3 @@ testDefaultsMatchRuntimeContract(); testRejectsUnsafeRangesAndPartialTelegramConfig(); testSummaryDoesNotExposeSecretsOrAllowedUserIds(); console.log('runtime config ok'); - diff --git a/backend/src/utils/runtimeConfig.ts b/backend/src/utils/runtimeConfig.ts index 2fed8f7..7f843a5 100644 --- a/backend/src/utils/runtimeConfig.ts +++ b/backend/src/utils/runtimeConfig.ts @@ -41,6 +41,7 @@ const NUMBER_SPECS: NumberSpec[] = [ { name: 'TG_DISK_WATERMARK_RECHECK_MS', fallback: 30_000, min: 5_000, max: 3_600_000 }, { name: 'TG_DISK_WATERMARK_MAX_WAIT_MS', fallback: 0, min: 0, max: 604_800_000 }, { name: 'TG_DEBUG_LOG_MAX_MB', fallback: 5, min: 1, max: 1024 }, + { name: 'STORAGE_PROBE_TIMEOUT_MS', fallback: 15_000, min: 1_000, max: 60_000 }, { name: 'WEBDAV_INACTIVITY_TIMEOUT_MS', fallback: 300_000, min: 30_000, max: 86_400_000 }, { name: 'WEBDAV_UPLOAD_TIMEOUT_MS', fallback: 21_600_000, min: 60_000, max: 604_800_000 }, { name: 'YTDLP_MAX_CONCURRENT', fallback: 1, min: 1, max: 16 }, @@ -163,6 +164,7 @@ export function validateRuntimeConfig(env: RuntimeEnv = process.env): RuntimeCon workDir: env.YTDLP_WORK_DIR || './data/uploads/ytdlp', }, storage: { + probeTimeoutMs: numbers.STORAGE_PROBE_TIMEOUT_MS, webdavInactivityTimeoutMs: numbers.WEBDAV_INACTIVITY_TIMEOUT_MS, webdavUploadTimeoutMs: numbers.WEBDAV_UPLOAD_TIMEOUT_MS, allowInsecureEndpoints, @@ -179,4 +181,3 @@ export function validateRuntimeConfig(env: RuntimeEnv = process.env): RuntimeCon export function logRuntimeConfigSummary(summary: RuntimeConfigSummary): void { console.log(`[config] effective=${JSON.stringify(summary)}`); } - diff --git a/docker-compose.yml b/docker-compose.yml index 78c1871..002c8de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,6 +117,7 @@ services: - YTDLP_BIN=${YTDLP_BIN:-yt-dlp} - YTDLP_WORK_DIR=${YTDLP_WORK_DIR:-/data/uploads/ytdlp} - YTDLP_MAX_CONCURRENT=${YTDLP_MAX_CONCURRENT:-1} + - STORAGE_PROBE_TIMEOUT_MS=${STORAGE_PROBE_TIMEOUT_MS:-15000} - WEBDAV_INACTIVITY_TIMEOUT_MS=${WEBDAV_INACTIVITY_TIMEOUT_MS:-300000} - WEBDAV_UPLOAD_TIMEOUT_MS=${WEBDAV_UPLOAD_TIMEOUT_MS:-21600000} - TOTP_SECRET=${TOTP_SECRET:-} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 349dc69..cac69c0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -282,6 +282,34 @@ function getHeaders(additionalHeaders: Record = {}): HeadersInit }; } +async function postStorageAccount(path: string, body: unknown, providerLabel: string): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 65_000); + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { + credentials: 'include', + method: 'POST', + headers: getHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`${providerLabel} 连接测试超时,请检查服务器地址和网络`); + } + throw error; + } finally { + window.clearTimeout(timeout); + } + if (response.status === 401) throw new Error('UNAUTHORIZED'); + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error(error?.error || `添加 ${providerLabel} 账户失败`); + } + return response.json(); +} + class FileAPI { private uploadCapabilitiesPromise: Promise | null = null; @@ -933,50 +961,17 @@ class FileAPI { // 添加 Aliyun OSS 账户 async addAliyunOSSAccount(name: string, region: string, accessKeyId: string, accessKeySecret: string, bucket: string): Promise<{ success: boolean; message: string; accountId: string }> { - const response = await fetch(`${API_BASE}/api/storage/config/aliyun-oss`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ name, region, accessKeyId, accessKeySecret, bucket }), - }); - if (response.status === 401) throw new Error('UNAUTHORIZED'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || '添加 Aliyun OSS 账户失败'); - } - return response.json(); + return postStorageAccount('/api/storage/config/aliyun-oss', { name, region, accessKeyId, accessKeySecret, bucket }, 'Aliyun OSS'); } // 添加 S3 账户 async addS3Account(name: string, endpoint: string, region: string, accessKeyId: string, accessKeySecret: string, bucket: string, forcePathStyle: boolean = false): Promise<{ success: boolean; message: string; accountId: string }> { - const response = await fetch(`${API_BASE}/api/storage/config/s3`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ name, endpoint, region, accessKeyId, accessKeySecret, bucket, forcePathStyle }), - }); - if (response.status === 401) throw new Error('UNAUTHORIZED'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || '添加 S3 账户失败'); - } - return response.json(); + return postStorageAccount('/api/storage/config/s3', { name, endpoint, region, accessKeyId, accessKeySecret, bucket, forcePathStyle }, 'S3'); } // 添加 WebDAV 账户 async addWebDAVAccount(name: string, url: string, username?: string, password?: string): Promise<{ success: boolean; message: string; accountId: string }> { - const response = await fetch(`${API_BASE}/api/storage/config/webdav`, { - credentials: 'include', - method: 'POST', - headers: getHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ name, url, username, password }), - }); - if (response.status === 401) throw new Error('UNAUTHORIZED'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || '添加 WebDAV 账户失败'); - } - return response.json(); + return postStorageAccount('/api/storage/config/webdav', { name, url, username, password }, 'WebDAV'); } // 切换存储提供商或账户 diff --git a/frontend/src/services/storageProductExperience.test.ts b/frontend/src/services/storageProductExperience.test.ts index 2684da9..0eace7d 100644 --- a/frontend/src/services/storageProductExperience.test.ts +++ b/frontend/src/services/storageProductExperience.test.ts @@ -21,3 +21,11 @@ test('storage account deletion previews impact before execution', () => { assert.match(settings, /activeLeaseCount/); assert.match(settings, /不会删除云端原文件/); }); + +test('cloud storage account creation cannot leave the settings form saving forever', () => { + assert.match(api, /setTimeout\(\(\) => controller\.abort\(\), 65_000\)/); + assert.match(api, /providerLabel} 连接测试超时/); + assert.match(api, /postStorageAccount\('\/api\/storage\/config\/aliyun-oss'/); + assert.match(api, /postStorageAccount\('\/api\/storage\/config\/s3'/); + assert.match(api, /postStorageAccount\('\/api\/storage\/config\/webdav'/); +});