fix: bound cloud storage account probes

This commit is contained in:
hicocos committed 2026-08-18 22:15:40 +08:00
1 parent 8630174d40
commit 6b2ee50a9e
11 files changed
+223 -54

No files matched your search

+2 -1
View File
@@ -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
+64 -8
View File
@@ -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
+22 -2
View File
@@ -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);
@@ -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<void>(() => 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<void>((_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);
});
+39 -5
View File
@@ -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<T>(operation: Promise<T>, provider: string, timeoutMs: number): Promise<T> {
let timeout: ReturnType<typeof setTimeout>;
const deadline = new Promise<never>((_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<void> {
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<void> {
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);
@@ -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<void>((_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 = {
+5 -1
View File
@@ -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');
+2 -1
View File
@@ -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)}`);
}
+1
View File
@@ -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:-}
+31 -36
View File
@@ -282,6 +282,34 @@ function getHeaders(additionalHeaders: Record<string, string> = {}): HeadersInit
};
}
async function postStorageAccount<T>(path: string, body: unknown, providerLabel: string): Promise<T> {
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<UploadCapabilities> | 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');
}
// 切换存储提供商或账户
@@ -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'/);
});