fix: bound cloud storage account probes
This commit is contained in:
1 parent
8630174d40
commit
6b2ee50a9e
11 files changed
+223
-54
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
|
||||
Vendored
+64
-8
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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'/);
|
||||
});
|
||||
Reference in new issue
Block a user