whistle.mock-plugins 1.3.1 → 1.4.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/VERSION.md +29 -0
  2. package/app/cgi-bin/interfaces.js +2 -0
  3. package/app/cgi-bin/settings.js +1 -0
  4. package/app/cgi-bin/stats.js +22 -12
  5. package/app/cgi-bin/webdav.js +56 -0
  6. package/app/public/index.html +1 -1
  7. package/app/public/js/11.index.js +2 -0
  8. package/app/public/js/11.index.js.LICENSE.txt +5 -0
  9. package/app/public/js/704.index.js +2 -0
  10. package/app/public/js/704.index.js.LICENSE.txt +1 -0
  11. package/app/public/js/798.index.js +2 -0
  12. package/app/public/js/798.index.js.LICENSE.txt +53 -0
  13. package/app/public/js/807.index.js +2 -0
  14. package/app/public/js/807.index.js.LICENSE.txt +1 -0
  15. package/app/public/js/968.index.js +2 -0
  16. package/app/public/js/968.index.js.LICENSE.txt +12 -0
  17. package/app/public/js/index.js +1 -1
  18. package/app/public/js/index.js.LICENSE.txt +0 -173
  19. package/lib/routes/settings.js +1 -0
  20. package/lib/webdav-sync.js +806 -0
  21. package/package.json +9 -2
  22. package/API.md +0 -247
  23. package/COLUMN_RESIZE_IMPLEMENTATION.md +0 -162
  24. package/FEATURE_ENHANCEMENT.md +0 -157
  25. package/TEST_GUIDE.md +0 -156
  26. package/data/features.json +0 -1
  27. package/data/interfaces.json +0 -1
  28. package/index.html +0 -52
  29. package/initialize.js +0 -32
  30. package/public/bundle.js +0 -2
  31. package/public/bundle.js.LICENSE.txt +0 -38
  32. package/public/index.html +0 -1
  33. package/public/plugin.mock-plugin.html +0 -14
  34. package/rules.txt +0 -22
  35. package/ui/src/App.css +0 -173
  36. package/ui/src/components/AppLayout.js +0 -407
  37. package/ui/src/components/FeatureCard.js +0 -58
  38. package/ui/src/components/KeyboardShortcutsHelp.js +0 -58
  39. package/ui/src/components/Layout.js +0 -94
  40. package/ui/src/components/MainNav.js +0 -42
  41. package/ui/src/components/PageContainer.js +0 -16
  42. package/ui/src/components/VersionModal.js +0 -99
  43. package/ui/src/components/interface-management/DataManagementDrawer.js +0 -115
  44. package/ui/src/components/interface-management/DataManagementModal.js +0 -110
  45. package/ui/src/components/interface-management/InterfaceTestModal.js +0 -313
  46. package/ui/src/components/interface-management/PreviewModal.js +0 -48
  47. package/ui/src/components/interface-management/README.md +0 -184
  48. package/ui/src/components/interface-management/RealResponseRecorder.js +0 -165
  49. package/ui/src/components/interface-management/ResponseContentEditor.js +0 -645
  50. package/ui/src/components/interface-management/constants.js +0 -36
  51. package/ui/src/components/interface-management/index.js +0 -7
  52. package/ui/src/components/interface-management/utils.js +0 -103
  53. package/ui/src/hooks/useKeyboardShortcuts.js +0 -99
  54. package/ui/src/index.html +0 -14
  55. package/ui/src/index.js +0 -50
  56. package/ui/src/pages/InterfaceManagementV2.js +0 -2351
  57. package/ui/src/pages/MockDataV2.js +0 -1089
  58. package/ui/src/pages/Settings.js +0 -299
  59. package/ui/src/pages/Sniffer.js +0 -521
  60. package/ui/src/pages/Stats.js +0 -288
  61. package/ui/src/services/del_api.js +0 -192
  62. package/ui/src/styles/app-layout.css +0 -373
  63. package/ui/src/styles/cyber-theme.css +0 -1234
  64. package/ui/src/styles/earth-theme.css +0 -618
  65. package/ui/src/styles/global.css +0 -224
  66. package/ui/src/styles/interface-management.css +0 -1321
  67. package/ui/src/styles/mock-data.css +0 -1185
  68. package/ui/src/styles/settings.css +0 -252
  69. package/ui/src/styles/version-modal.css +0 -116
  70. package/webpack.config.js +0 -54
@@ -0,0 +1,806 @@
1
+ const crypto = require('crypto');
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const axios = require('axios');
5
+ const zlib = require('zlib');
6
+
7
+ const WEBDAV_FILE_NAME = 'whistle-mock-plugin-manifest.json';
8
+ const LEGACY_COMPRESSED_WEBDAV_FILE_NAME = 'whistle-mock-plugin-sync.json.gz';
9
+ const LEGACY_WEBDAV_FILE_NAME = 'whistle-mock-plugin-sync.json';
10
+ const MODULE_FILE_PREFIX = 'whistle-mock-module-';
11
+ const DEFAULT_WEBDAV_ENDPOINT = 'https://webdav.example.com/webdav/whistle-mock-plugin-sync/';
12
+ const CONFIG_FILE_NAME = 'webdav-config.json';
13
+ const STATE_FILE_NAME = 'webdav-sync-state.json';
14
+ const SYNC_VERSION = 1;
15
+ const REQUEST_TIMEOUT = 30000;
16
+ const WRITE_REQUEST_TIMEOUT = 10 * 60 * 1000;
17
+ const MOVE_REQUEST_TIMEOUT = 60000;
18
+ const MAX_TRANSFER_SIZE = 150 * 1024 * 1024;
19
+ const MAX_UNCOMPRESSED_MODULE_SIZE = 256 * 1024 * 1024;
20
+ const MODULE_FILE_PATTERN = /^whistle-mock-module-[a-f0-9]{16}-[a-f0-9]{24}\.json\.gz$/;
21
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
22
+
23
+ const createError = (message, statusCode = 500, conflict) => {
24
+ const error = new Error(message);
25
+ error.statusCode = statusCode;
26
+ if (conflict) error.conflict = conflict;
27
+ return error;
28
+ };
29
+
30
+ const normalizeWebDavEndpoint = (value) => {
31
+ const input = typeof value == 'string' ? value.trim() : '';
32
+ if (!input) throw createError('WebDAV 目录地址不能为空', 400);
33
+
34
+ let url;
35
+ try {
36
+ url = new URL(input);
37
+ } catch (error) {
38
+ throw createError('WebDAV 目录地址格式不正确', 400);
39
+ }
40
+
41
+ if (url.protocol != 'http:' && url.protocol != 'https:') {
42
+ throw createError('WebDAV 目录地址只支持 HTTP 或 HTTPS', 400);
43
+ }
44
+ if (url.username || url.password) {
45
+ throw createError('WebDAV 目录地址中不要包含账号或密码', 400);
46
+ }
47
+
48
+ url.search = '';
49
+ url.hash = '';
50
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
51
+ return url.toString();
52
+ };
53
+
54
+ const getWebDavFileUrl = (endpoint) => {
55
+ return new URL(WEBDAV_FILE_NAME, normalizeWebDavEndpoint(endpoint)).toString();
56
+ };
57
+
58
+ const getLegacyWebDavFileUrl = (endpoint) => {
59
+ return new URL(LEGACY_WEBDAV_FILE_NAME, normalizeWebDavEndpoint(endpoint)).toString();
60
+ };
61
+
62
+ const getModuleFileUrl = (endpoint, fileName) => {
63
+ if (!MODULE_FILE_PATTERN.test(fileName || '')) {
64
+ throw createError('WebDAV 模块文件名无效', 422);
65
+ }
66
+ return new URL(fileName, normalizeWebDavEndpoint(endpoint)).toString();
67
+ };
68
+
69
+ const getTempFileUrl = (endpoint, fileName) => {
70
+ const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
71
+ return new URL(`.${fileName}.${suffix}.tmp`, normalizeWebDavEndpoint(endpoint)).toString();
72
+ };
73
+
74
+ const hashBuffer = (value) => {
75
+ return crypto.createHash('sha256').update(value).digest('hex');
76
+ };
77
+
78
+ const createModuleContent = (feature, interfaces) => ({ feature, interfaces });
79
+
80
+ const getModuleContentHash = (feature, interfaces) => {
81
+ return hashBuffer(Buffer.from(JSON.stringify(createModuleContent(feature, interfaces))));
82
+ };
83
+
84
+ const createModuleFileName = (featureId, compressedSha256) => {
85
+ const idHash = hashBuffer(Buffer.from(featureId)).slice(0, 16);
86
+ return `${MODULE_FILE_PREFIX}${idHash}-${compressedSha256.slice(0, 24)}.json.gz`;
87
+ };
88
+
89
+ const getResponseHeader = (headers, name) => {
90
+ if (!headers) return null;
91
+ if (typeof headers.get == 'function') return headers.get(name) || null;
92
+ return headers[name.toLowerCase()] || headers[name] || null;
93
+ };
94
+
95
+ const responseError = (action, response) => {
96
+ const status = response && response.status;
97
+ if (status == 401) return createError(`${action}失败:用户名或密码错误`, 401);
98
+ if (status == 403) return createError(`${action}失败:当前用户没有目录读写权限`, 403);
99
+ if (status == 404) return createError(`${action}失败:WebDAV 目录不存在`, 404);
100
+ if (status == 409) return createError(`${action}失败:请先在 WebDAV 中创建专用同步目录`, 409);
101
+ if (status == 412) return createError('远端模块清单已被其他设备更新,请刷新后重试', 412);
102
+ if (status == 405) return createError(`${action}失败:服务器不支持所需的 WebDAV 方法,请检查地址和服务能力`, 405);
103
+ if (status == 423) return createError(`${action}失败:WebDAV 资源已被锁定`, 423);
104
+ if (status == 507) return createError(`${action}失败:WebDAV 存储空间不足`, 507);
105
+
106
+ let detail = '';
107
+ const data = response && response.data;
108
+ if (Buffer.isBuffer(data)) detail = data.toString('utf8');
109
+ else if (typeof data == 'string') detail = data;
110
+ else if (data && typeof data == 'object') detail = data.message || data.error || data.detail || '';
111
+ detail = String(detail || '')
112
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
113
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
114
+ .replace(/<[^>]+>/g, ' ')
115
+ .replace(/\s+/g, ' ')
116
+ .trim()
117
+ .slice(0, 200);
118
+ return createError(`${action}失败 (${status || '未知状态'})${detail ? `:${detail}` : ''}`, status || 502);
119
+ };
120
+
121
+ const networkError = (action, error) => {
122
+ const code = error && (error.code || (error.cause && error.cause.code));
123
+ const messages = {
124
+ ENOTFOUND: '域名解析失败,请检查 WebDAV 地址',
125
+ EAI_AGAIN: '域名解析超时,请稍后重试',
126
+ ECONNREFUSED: '连接被拒绝,请检查服务地址和端口',
127
+ ECONNRESET: '连接被远端重置',
128
+ ETIMEDOUT: '连接超时,请检查网络或防火墙',
129
+ ECONNABORTED: '请求超时,请检查网络或 WebDAV 服务',
130
+ DEPTH_ZERO_SELF_SIGNED_CERT: '服务器使用了不受信任的自签名证书',
131
+ SELF_SIGNED_CERT_IN_CHAIN: '服务器证书链包含不受信任的自签名证书',
132
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: '无法验证服务器 TLS 证书',
133
+ ERR_TLS_CERT_ALTNAME_INVALID: '服务器证书与 WebDAV 域名不匹配',
134
+ EPROTO: 'TLS 连接失败,请检查 HTTPS 配置',
135
+ };
136
+ const detail = messages[code]
137
+ || (error && error.message)
138
+ || (error && error.cause && error.cause.message)
139
+ || '未知网络错误';
140
+ return createError(`${action}失败${code ? ` [${code}]` : ''}:${detail}`, 502);
141
+ };
142
+
143
+ const validateFeature = (feature, messagePrefix = '同步数据') => {
144
+ if (!feature || typeof feature != 'object' || typeof feature.id != 'string' || !feature.id
145
+ || typeof feature.name != 'string' || !feature.name) {
146
+ throw createError(`${messagePrefix}包含无效的功能模块`, 422);
147
+ }
148
+ return feature;
149
+ };
150
+
151
+ const validateModuleData = (value, expectedFeatureId) => {
152
+ if (!value || typeof value != 'object' || value.version != SYNC_VERSION || !Number.isFinite(value.updatedAt)) {
153
+ throw createError('WebDAV 模块文件版本或更新时间无效', 422);
154
+ }
155
+ const feature = validateFeature(value.feature, 'WebDAV 模块文件');
156
+ if (expectedFeatureId && feature.id != expectedFeatureId) {
157
+ throw createError(`WebDAV 模块文件与清单中的模块 ID 不一致:${expectedFeatureId}`, 422);
158
+ }
159
+ if (!Array.isArray(value.interfaces)) {
160
+ throw createError(`模块“${feature.name}”缺少接口数据`, 422);
161
+ }
162
+
163
+ const interfaceIds = new Set();
164
+ value.interfaces.forEach((interfaceItem) => {
165
+ if (!interfaceItem || typeof interfaceItem != 'object' || typeof interfaceItem.id != 'string' || !interfaceItem.id) {
166
+ throw createError(`模块“${feature.name}”包含无效的接口配置`, 422);
167
+ }
168
+ if (interfaceIds.has(interfaceItem.id)) {
169
+ throw createError(`模块“${feature.name}”包含重复的接口 ID:${interfaceItem.id}`, 422);
170
+ }
171
+ if (interfaceItem.featureId != feature.id) {
172
+ throw createError(`接口 ${interfaceItem.id} 未关联模块“${feature.name}”`, 422);
173
+ }
174
+ interfaceIds.add(interfaceItem.id);
175
+ });
176
+
177
+ return {
178
+ version: SYNC_VERSION,
179
+ updatedAt: value.updatedAt,
180
+ feature,
181
+ interfaces: value.interfaces,
182
+ };
183
+ };
184
+
185
+ const validateSyncData = (value) => {
186
+ if (!value || typeof value != 'object') {
187
+ throw createError('同步数据格式不正确', 422);
188
+ }
189
+ if (value.version != SYNC_VERSION || !Number.isFinite(value.updatedAt)) {
190
+ throw createError('同步数据版本或更新时间无效', 422);
191
+ }
192
+ if (!Array.isArray(value.features) || !Array.isArray(value.interfaces)) {
193
+ throw createError('同步数据缺少功能模块或接口数据', 422);
194
+ }
195
+
196
+ const featureIds = new Set();
197
+ value.features.forEach((feature) => {
198
+ validateFeature(feature);
199
+ if (featureIds.has(feature.id)) {
200
+ throw createError(`同步数据包含重复的功能模块 ID:${feature.id}`, 422);
201
+ }
202
+ featureIds.add(feature.id);
203
+ });
204
+
205
+ const interfaceIds = new Set();
206
+ value.interfaces.forEach((interfaceItem) => {
207
+ if (!interfaceItem || typeof interfaceItem != 'object' || typeof interfaceItem.id != 'string' || !interfaceItem.id) {
208
+ throw createError('同步数据包含无效的接口配置', 422);
209
+ }
210
+ if (interfaceIds.has(interfaceItem.id)) {
211
+ throw createError(`同步数据包含重复的接口 ID:${interfaceItem.id}`, 422);
212
+ }
213
+ if (typeof interfaceItem.featureId != 'string' || !featureIds.has(interfaceItem.featureId)) {
214
+ throw createError(`接口 ${interfaceItem.id} 未关联有效的功能模块`, 422);
215
+ }
216
+ interfaceIds.add(interfaceItem.id);
217
+ });
218
+
219
+ return {
220
+ version: SYNC_VERSION,
221
+ updatedAt: value.updatedAt,
222
+ features: value.features,
223
+ interfaces: value.interfaces,
224
+ };
225
+ };
226
+
227
+ const validateManifest = (value) => {
228
+ if (!value || typeof value != 'object' || value.version != SYNC_VERSION || !Number.isFinite(value.updatedAt)) {
229
+ throw createError('WebDAV 模块清单版本或更新时间无效', 422);
230
+ }
231
+ if (!Array.isArray(value.modules)) {
232
+ throw createError('WebDAV 模块清单缺少模块列表', 422);
233
+ }
234
+
235
+ const moduleIds = new Set();
236
+ const fileNames = new Set();
237
+ const modules = value.modules.map((moduleItem) => {
238
+ if (!moduleItem || typeof moduleItem != 'object' || typeof moduleItem.id != 'string' || !moduleItem.id
239
+ || typeof moduleItem.name != 'string' || !moduleItem.name) {
240
+ throw createError('WebDAV 模块清单包含无效的模块', 422);
241
+ }
242
+ if (moduleIds.has(moduleItem.id)) {
243
+ throw createError(`WebDAV 模块清单包含重复的模块 ID:${moduleItem.id}`, 422);
244
+ }
245
+ if (!MODULE_FILE_PATTERN.test(moduleItem.fileName || '') || fileNames.has(moduleItem.fileName)) {
246
+ throw createError(`模块“${moduleItem.name}”的压缩文件名无效`, 422);
247
+ }
248
+ if (!Number.isFinite(moduleItem.updatedAt) || !Number.isInteger(moduleItem.interfaceCount) || moduleItem.interfaceCount < 0
249
+ || !SHA256_PATTERN.test(moduleItem.sha256 || '') || !SHA256_PATTERN.test(moduleItem.contentSha256 || '')) {
250
+ throw createError(`模块“${moduleItem.name}”的清单信息无效`, 422);
251
+ }
252
+ moduleIds.add(moduleItem.id);
253
+ fileNames.add(moduleItem.fileName);
254
+ return {
255
+ id: moduleItem.id,
256
+ name: moduleItem.name,
257
+ fileName: moduleItem.fileName,
258
+ updatedAt: moduleItem.updatedAt,
259
+ interfaceCount: moduleItem.interfaceCount,
260
+ sha256: moduleItem.sha256,
261
+ contentSha256: moduleItem.contentSha256,
262
+ };
263
+ });
264
+
265
+ return { version: SYNC_VERSION, updatedAt: value.updatedAt, modules };
266
+ };
267
+
268
+ const createWebDavSync = (dataDir, httpClient = axios) => {
269
+ const configFile = path.join(dataDir, CONFIG_FILE_NAME);
270
+ const stateFile = path.join(dataDir, STATE_FILE_NAME);
271
+ const featuresFile = path.join(dataDir, 'features.json');
272
+ const interfacesFile = path.join(dataDir, 'interfaces.json');
273
+
274
+ const readConfig = () => {
275
+ try {
276
+ if (!fs.existsSync(configFile)) return null;
277
+ const saved = fs.readJsonSync(configFile);
278
+ if (!saved || !saved.endpoint || !saved.username || !saved.password) return null;
279
+ return {
280
+ endpoint: normalizeWebDavEndpoint(saved.endpoint),
281
+ username: String(saved.username).trim(),
282
+ password: String(saved.password),
283
+ };
284
+ } catch (error) {
285
+ return null;
286
+ }
287
+ };
288
+
289
+ const requireConfig = () => {
290
+ const config = readConfig();
291
+ if (!config) throw createError('尚未配置 WebDAV 连接', 400);
292
+ return config;
293
+ };
294
+
295
+ const saveConfig = (config) => {
296
+ fs.ensureDirSync(dataDir);
297
+ fs.writeJsonSync(configFile, config, { spaces: 2, mode: 0o600 });
298
+ try {
299
+ fs.chmodSync(configFile, 0o600);
300
+ } catch (error) {
301
+ // 部分文件系统不支持 chmod,不影响本地配置保存。
302
+ }
303
+ };
304
+
305
+ const readSyncState = (config) => {
306
+ try {
307
+ if (!fs.existsSync(stateFile)) return {};
308
+ const saved = fs.readJsonSync(stateFile);
309
+ if (!saved || saved.endpoint != config.endpoint || !saved.modules || typeof saved.modules != 'object') return {};
310
+ return Object.keys(saved.modules).reduce((result, featureId) => {
311
+ const contentSha256 = saved.modules[featureId];
312
+ if (typeof featureId == 'string' && featureId && SHA256_PATTERN.test(contentSha256 || '')) {
313
+ result[featureId] = contentSha256;
314
+ }
315
+ return result;
316
+ }, {});
317
+ } catch (error) {
318
+ return {};
319
+ }
320
+ };
321
+
322
+ const saveSyncState = (config, moduleEntries) => {
323
+ try {
324
+ const modules = readSyncState(config);
325
+ moduleEntries.forEach((moduleItem) => {
326
+ modules[moduleItem.id] = moduleItem.contentSha256;
327
+ });
328
+ fs.ensureDirSync(dataDir);
329
+ fs.writeJsonSync(stateFile, { endpoint: config.endpoint, modules }, { spaces: 2, mode: 0o600 });
330
+ try {
331
+ fs.chmodSync(stateFile, 0o600);
332
+ } catch (error) {
333
+ // 同步基线不含凭据,chmod 失败不影响同步结果。
334
+ }
335
+ } catch (error) {
336
+ console.warn('[webdav] 保存模块同步基线失败:', error.message);
337
+ }
338
+ };
339
+
340
+ const getLocalUpdatedAt = () => {
341
+ return Math.max(
342
+ fs.existsSync(featuresFile) ? fs.statSync(featuresFile).mtimeMs : 0,
343
+ fs.existsSync(interfacesFile) ? fs.statSync(interfacesFile).mtimeMs : 0
344
+ );
345
+ };
346
+
347
+ const readLocalData = () => {
348
+ const featuresData = fs.existsSync(featuresFile) ? fs.readJsonSync(featuresFile) : { features: [] };
349
+ const interfacesData = fs.existsSync(interfacesFile) ? fs.readJsonSync(interfacesFile) : { interfaces: [] };
350
+ return validateSyncData({
351
+ version: SYNC_VERSION,
352
+ updatedAt: getLocalUpdatedAt() || Date.now(),
353
+ features: Array.isArray(featuresData.features) ? featuresData.features : [],
354
+ interfaces: Array.isArray(interfacesData.interfaces) ? interfacesData.interfaces : [],
355
+ });
356
+ };
357
+
358
+ const getLocalModules = (localData = readLocalData()) => {
359
+ return localData.features.map((feature) => {
360
+ const interfaces = localData.interfaces.filter(interfaceItem => interfaceItem.featureId == feature.id);
361
+ const timestamps = [feature.updatedAt, feature.createdAt]
362
+ .concat(interfaces.map(interfaceItem => interfaceItem.updatedAt))
363
+ .map(value => Date.parse(value))
364
+ .filter(Number.isFinite);
365
+ return {
366
+ id: feature.id,
367
+ name: feature.name,
368
+ interfaceCount: interfaces.length,
369
+ updatedAt: Math.max(localData.updatedAt, ...timestamps),
370
+ contentSha256: getModuleContentHash(feature, interfaces),
371
+ feature,
372
+ interfaces,
373
+ };
374
+ });
375
+ };
376
+
377
+ const request = async (action, config, requestConfig) => {
378
+ try {
379
+ return await httpClient({
380
+ timeout: REQUEST_TIMEOUT,
381
+ maxContentLength: MAX_TRANSFER_SIZE,
382
+ maxBodyLength: MAX_TRANSFER_SIZE,
383
+ validateStatus: () => true,
384
+ auth: {
385
+ username: config.username,
386
+ password: config.password,
387
+ },
388
+ ...requestConfig,
389
+ });
390
+ } catch (error) {
391
+ throw networkError(action, error);
392
+ }
393
+ };
394
+
395
+ const testConnection = async (candidate) => {
396
+ const config = {
397
+ endpoint: normalizeWebDavEndpoint(candidate.endpoint),
398
+ username: typeof candidate.username == 'string' ? candidate.username.trim() : '',
399
+ password: typeof candidate.password == 'string' ? candidate.password : '',
400
+ };
401
+ if (!config.username) throw createError('WebDAV 用户名不能为空', 400);
402
+ if (!config.password) throw createError('WebDAV 密码不能为空', 400);
403
+
404
+ const response = await request('测试 WebDAV 连接', config, {
405
+ method: 'PROPFIND',
406
+ url: config.endpoint,
407
+ headers: { Depth: '0' },
408
+ });
409
+ if ((response.status >= 200 && response.status < 300) || response.status == 207) return config;
410
+ throw responseError('测试 WebDAV 连接', response);
411
+ };
412
+
413
+ const readRemoteManifest = async (config, options = {}) => {
414
+ const response = await request('读取 WebDAV 模块清单', config, {
415
+ method: 'GET',
416
+ url: getWebDavFileUrl(config.endpoint),
417
+ headers: { 'Cache-Control': 'no-cache' },
418
+ responseType: 'arraybuffer',
419
+ });
420
+ if (response.status == 404) return null;
421
+ if (response.status < 200 || response.status >= 300) throw responseError('读取 WebDAV 模块清单', response);
422
+
423
+ const remoteMeta = {
424
+ etag: getResponseHeader(response.headers, 'etag'),
425
+ lastModified: getResponseHeader(response.headers, 'last-modified'),
426
+ };
427
+ try {
428
+ let data = response.data;
429
+ if (Buffer.isBuffer(data)) data = data.toString('utf8');
430
+ else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) data = Buffer.from(data).toString('utf8');
431
+ if (typeof data == 'string') data = JSON.parse(data);
432
+ return { data: validateManifest(data), invalid: false, ...remoteMeta };
433
+ } catch (error) {
434
+ if (options.allowInvalid) return { data: null, invalid: true, ...remoteMeta };
435
+ if (error && error.statusCode) throw error;
436
+ throw createError('WebDAV 模块清单不是有效的 JSON', 422);
437
+ }
438
+ };
439
+
440
+ const readRemoteModule = async (config, moduleItem) => {
441
+ const response = await request(`读取模块“${moduleItem.name}”`, config, {
442
+ method: 'GET',
443
+ url: getModuleFileUrl(config.endpoint, moduleItem.fileName),
444
+ headers: { 'Cache-Control': 'no-cache' },
445
+ responseType: 'arraybuffer',
446
+ });
447
+ if (response.status == 404) throw createError(`远端模块“${moduleItem.name}”的压缩文件不存在`, 404);
448
+ if (response.status < 200 || response.status >= 300) throw responseError(`读取模块“${moduleItem.name}”`, response);
449
+
450
+ let compressed = Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data || '');
451
+ if (compressed.length > MAX_TRANSFER_SIZE) throw createError(`模块“${moduleItem.name}”的压缩文件过大`, 413);
452
+ if (compressed.length < 2 || compressed[0] != 0x1f || compressed[1] != 0x8b) {
453
+ throw createError(`模块“${moduleItem.name}”不是有效的 gzip 文件`, 422);
454
+ }
455
+ if (hashBuffer(compressed) != moduleItem.sha256) {
456
+ throw createError(`模块“${moduleItem.name}”的压缩文件不完整或已被修改`, 422);
457
+ }
458
+
459
+ let uncompressed;
460
+ try {
461
+ uncompressed = zlib.gunzipSync(compressed, { maxOutputLength: MAX_UNCOMPRESSED_MODULE_SIZE });
462
+ } catch (error) {
463
+ throw createError(`模块“${moduleItem.name}”的压缩文件已损坏或不完整`, 422);
464
+ }
465
+ if (uncompressed.length > MAX_UNCOMPRESSED_MODULE_SIZE) {
466
+ throw createError(`模块“${moduleItem.name}”解压后数据过大`, 413);
467
+ }
468
+
469
+ let parsed;
470
+ try {
471
+ parsed = JSON.parse(uncompressed.toString('utf8'));
472
+ } catch (error) {
473
+ throw createError(`模块“${moduleItem.name}”解压后不是有效的 JSON`, 422);
474
+ }
475
+ const data = validateModuleData(parsed, moduleItem.id);
476
+ if (data.feature.name != moduleItem.name || data.interfaces.length != moduleItem.interfaceCount
477
+ || getModuleContentHash(data.feature, data.interfaces) != moduleItem.contentSha256) {
478
+ throw createError(`模块“${moduleItem.name}”与远端清单不一致`, 422);
479
+ }
480
+ return data;
481
+ };
482
+
483
+ const cleanupFile = async (config, fileUrl) => {
484
+ try {
485
+ const response = await request('清理 WebDAV 文件', config, {
486
+ method: 'DELETE',
487
+ url: fileUrl,
488
+ timeout: MOVE_REQUEST_TIMEOUT,
489
+ });
490
+ if ((response.status < 200 || response.status >= 300) && response.status != 404) {
491
+ console.warn('[webdav] 清理文件失败:', response.status);
492
+ }
493
+ } catch (error) {
494
+ console.warn('[webdav] 清理文件失败:', error.message);
495
+ }
496
+ };
497
+
498
+ const uploadModule = async (config, moduleName, fileName, compressed) => {
499
+ const tempUrl = getTempFileUrl(config.endpoint, fileName);
500
+ const finalUrl = getModuleFileUrl(config.endpoint, fileName);
501
+ let moved = false;
502
+ try {
503
+ const putResponse = await request(`上传模块“${moduleName}”`, config, {
504
+ method: 'PUT',
505
+ url: tempUrl,
506
+ headers: {
507
+ 'Content-Type': 'application/gzip',
508
+ 'If-None-Match': '*',
509
+ },
510
+ timeout: WRITE_REQUEST_TIMEOUT,
511
+ data: compressed,
512
+ });
513
+ if (putResponse.status < 200 || putResponse.status >= 300) {
514
+ throw responseError(`上传模块“${moduleName}”`, putResponse);
515
+ }
516
+
517
+ const moveResponse = await request(`发布模块“${moduleName}”`, config, {
518
+ method: 'MOVE',
519
+ url: tempUrl,
520
+ headers: { Destination: finalUrl, Overwrite: 'T' },
521
+ timeout: MOVE_REQUEST_TIMEOUT,
522
+ });
523
+ if (moveResponse.status < 200 || moveResponse.status >= 300) {
524
+ throw responseError(`发布模块“${moduleName}”`, moveResponse);
525
+ }
526
+ moved = true;
527
+ } finally {
528
+ if (!moved) await cleanupFile(config, tempUrl);
529
+ }
530
+ };
531
+
532
+ const writeManifest = async (config, manifest, remote, options = {}) => {
533
+ const headers = { 'Content-Type': 'application/json; charset=utf-8' };
534
+ if (remote && remote.etag) headers['If-Match'] = remote.etag;
535
+ else if (remote && remote.lastModified) headers['If-Unmodified-Since'] = remote.lastModified;
536
+ else if (!remote) headers['If-None-Match'] = '*';
537
+ else if (!options.force) {
538
+ throw createError('WebDAV 服务未返回 ETag 或 Last-Modified,无法安全更新已有模块清单;确认后可强制覆盖', 409, 'REMOTE_UNVERIFIABLE');
539
+ }
540
+
541
+ const response = await request('写入 WebDAV 模块清单', config, {
542
+ method: 'PUT',
543
+ url: getWebDavFileUrl(config.endpoint),
544
+ headers,
545
+ timeout: MOVE_REQUEST_TIMEOUT,
546
+ data: Buffer.from(JSON.stringify(manifest)),
547
+ });
548
+ if (response.status < 200 || response.status >= 300) {
549
+ throw responseError('写入 WebDAV 模块清单', response);
550
+ }
551
+ };
552
+
553
+ const selectModules = (modules, featureIds, action) => {
554
+ if (featureIds == null) {
555
+ if (!modules.length) throw createError(`暂无可${action}的功能模块`, 400);
556
+ return modules;
557
+ }
558
+ if (!Array.isArray(featureIds) || !featureIds.length) {
559
+ throw createError(`请至少选择一个要${action}的功能模块`, 400);
560
+ }
561
+ const selectedIds = new Set();
562
+ featureIds.forEach((featureId) => {
563
+ if (typeof featureId != 'string' || !featureId || selectedIds.has(featureId)) {
564
+ throw createError(`${action}的功能模块 ID 无效或重复`, 400);
565
+ }
566
+ selectedIds.add(featureId);
567
+ });
568
+ const selected = modules.filter(moduleItem => selectedIds.has(moduleItem.id));
569
+ if (selected.length != selectedIds.size) {
570
+ const knownIds = new Set(modules.map(moduleItem => moduleItem.id));
571
+ const missingId = featureIds.find(featureId => !knownIds.has(featureId));
572
+ throw createError(`找不到要${action}的功能模块:${missingId}`, 400);
573
+ }
574
+ return selected;
575
+ };
576
+
577
+ const writeLocalData = (nextData) => {
578
+ const data = validateSyncData(nextData);
579
+ fs.ensureDirSync(dataDir);
580
+
581
+ const suffix = `${process.pid}-${Date.now()}`;
582
+ const featuresTempFile = path.join(dataDir, `.features-${suffix}.tmp`);
583
+ const interfacesTempFile = path.join(dataDir, `.interfaces-${suffix}.tmp`);
584
+ const oldFeatures = fs.existsSync(featuresFile) ? fs.readFileSync(featuresFile) : null;
585
+ const oldInterfaces = fs.existsSync(interfacesFile) ? fs.readFileSync(interfacesFile) : null;
586
+
587
+ try {
588
+ fs.writeJsonSync(featuresTempFile, { features: data.features }, { spaces: 2 });
589
+ fs.writeJsonSync(interfacesTempFile, { interfaces: data.interfaces }, { spaces: 2 });
590
+ fs.moveSync(featuresTempFile, featuresFile, { overwrite: true });
591
+ fs.moveSync(interfacesTempFile, interfacesFile, { overwrite: true });
592
+ } catch (error) {
593
+ if (oldFeatures) fs.writeFileSync(featuresFile, oldFeatures);
594
+ else fs.removeSync(featuresFile);
595
+ if (oldInterfaces) fs.writeFileSync(interfacesFile, oldInterfaces);
596
+ else fs.removeSync(interfacesFile);
597
+ throw createError(`覆盖本地配置失败:${error.message}`, 500);
598
+ } finally {
599
+ fs.removeSync(featuresTempFile);
600
+ fs.removeSync(interfacesTempFile);
601
+ }
602
+ };
603
+
604
+ const formatModuleStatus = moduleItem => ({
605
+ id: moduleItem.id,
606
+ name: moduleItem.name,
607
+ interfaceCount: moduleItem.interfaceCount,
608
+ updatedAt: moduleItem.updatedAt,
609
+ });
610
+
611
+ const formatStatus = (remote) => {
612
+ const config = readConfig();
613
+ const localModules = getLocalModules().map(formatModuleStatus);
614
+ return {
615
+ configured: !!config,
616
+ endpoint: config ? config.endpoint : DEFAULT_WEBDAV_ENDPOINT,
617
+ username: config ? config.username : '',
618
+ remoteFileName: WEBDAV_FILE_NAME,
619
+ remoteFileExists: remote === undefined ? null : !!remote,
620
+ remoteFileInvalid: !!(remote && remote.invalid),
621
+ remoteUpdatedAt: remote && remote.data ? remote.data.updatedAt : null,
622
+ localUpdatedAt: getLocalUpdatedAt() || null,
623
+ localModules,
624
+ remoteModules: remote && remote.data ? remote.data.modules.map(formatModuleStatus) : [],
625
+ };
626
+ };
627
+
628
+ return {
629
+ getStatus: () => formatStatus(),
630
+ test: async (input) => {
631
+ await testConnection(input);
632
+ return formatStatus();
633
+ },
634
+ connect: async (input) => {
635
+ const config = await testConnection(input);
636
+ saveConfig(config);
637
+ return formatStatus();
638
+ },
639
+ refresh: async () => {
640
+ const config = requireConfig();
641
+ return formatStatus(await readRemoteManifest(config, { allowInvalid: true }));
642
+ },
643
+ push: async (options = {}) => {
644
+ const force = options == true || options.force == true;
645
+ const featureIds = options == true || options == false ? null : options.featureIds;
646
+ const config = requireConfig();
647
+ const localModules = getLocalModules();
648
+ const selectedModules = selectModules(localModules, featureIds, '推送');
649
+ const remote = await readRemoteManifest(config, { allowInvalid: true });
650
+
651
+ if (!force && remote && remote.invalid) {
652
+ throw createError('远端模块清单已损坏,继续推送会用已选择的本地模块创建新清单', 409, 'REMOTE_INVALID');
653
+ }
654
+ const remoteModules = remote && remote.data ? remote.data.modules : [];
655
+ const remoteById = new Map(remoteModules.map(moduleItem => [moduleItem.id, moduleItem]));
656
+ const syncState = readSyncState(config);
657
+ const conflicting = selectedModules.find((localModule) => {
658
+ const remoteModule = remoteById.get(localModule.id);
659
+ if (!remoteModule || remoteModule.contentSha256 == localModule.contentSha256) return false;
660
+ const syncedHash = syncState[localModule.id];
661
+ return !syncedHash || remoteModule.contentSha256 != syncedHash;
662
+ });
663
+ if (!force && conflicting) {
664
+ throw createError(`远端模块“${conflicting.name}”与本地内容不同,继续推送会覆盖远端数据`, 409, 'REMOTE_NEWER');
665
+ }
666
+
667
+ const changedModules = selectedModules.filter((localModule) => {
668
+ const existing = remoteById.get(localModule.id);
669
+ return !existing || existing.contentSha256 != localModule.contentSha256;
670
+ });
671
+ if (!changedModules.length && remote && remote.data) {
672
+ saveSyncState(config, selectedModules);
673
+ return formatStatus(remote);
674
+ }
675
+ if (!force && remote && !remote.etag && !remote.lastModified) {
676
+ throw createError('WebDAV 服务未返回 ETag 或 Last-Modified,无法安全更新已有模块清单;确认后可强制覆盖', 409, 'REMOTE_UNVERIFIABLE');
677
+ }
678
+
679
+ const pushedAt = Date.now();
680
+ const nextById = new Map(remoteModules.map(moduleItem => [moduleItem.id, moduleItem]));
681
+ for (const localModule of changedModules) {
682
+ const modulePayload = {
683
+ version: SYNC_VERSION,
684
+ updatedAt: pushedAt,
685
+ ...createModuleContent(localModule.feature, localModule.interfaces),
686
+ };
687
+ const rawPayload = Buffer.from(JSON.stringify(modulePayload));
688
+ if (rawPayload.length > MAX_UNCOMPRESSED_MODULE_SIZE) {
689
+ throw createError(`模块“${localModule.name}”的数据超过单模块同步限制`, 413);
690
+ }
691
+ const compressed = zlib.gzipSync(rawPayload, {
692
+ level: zlib.constants.Z_BEST_SPEED,
693
+ });
694
+ if (compressed.length > MAX_TRANSFER_SIZE) {
695
+ throw createError(`模块“${localModule.name}”压缩后仍超过上传限制`, 413);
696
+ }
697
+ const sha256 = hashBuffer(compressed);
698
+ const entry = {
699
+ id: localModule.id,
700
+ name: localModule.name,
701
+ fileName: createModuleFileName(localModule.id, sha256),
702
+ updatedAt: pushedAt,
703
+ interfaceCount: localModule.interfaceCount,
704
+ sha256,
705
+ contentSha256: localModule.contentSha256,
706
+ };
707
+ await uploadModule(config, localModule.name, entry.fileName, compressed);
708
+ nextById.set(localModule.id, entry);
709
+ }
710
+
711
+ const nextModules = remoteModules
712
+ .map(moduleItem => nextById.get(moduleItem.id))
713
+ .concat(selectedModules
714
+ .filter(moduleItem => !remoteById.has(moduleItem.id))
715
+ .map(moduleItem => nextById.get(moduleItem.id)));
716
+ const nextManifest = validateManifest({ version: SYNC_VERSION, updatedAt: pushedAt, modules: nextModules });
717
+ await writeManifest(config, nextManifest, remote, { force });
718
+ saveSyncState(config, selectedModules.map(moduleItem => nextById.get(moduleItem.id)));
719
+ return formatStatus({ data: nextManifest, invalid: false });
720
+ },
721
+ pull: async (options = {}) => {
722
+ const force = options == true || options.force == true;
723
+ const featureIds = options == true || options == false ? null : options.featureIds;
724
+ const config = requireConfig();
725
+ const remote = await readRemoteManifest(config);
726
+ if (!remote || !remote.data.modules.length) {
727
+ throw createError('WebDAV 中暂无模块,请先推送本地模块', 404);
728
+ }
729
+
730
+ const selectedRemoteModules = selectModules(remote.data.modules, featureIds, '拉取');
731
+ const localData = readLocalData();
732
+ const localModules = getLocalModules(localData);
733
+ const localById = new Map(localModules.map(moduleItem => [moduleItem.id, moduleItem]));
734
+ const syncState = readSyncState(config);
735
+ const conflicting = selectedRemoteModules.find((remoteModule) => {
736
+ const localModule = localById.get(remoteModule.id);
737
+ if (!localModule || localModule.contentSha256 == remoteModule.contentSha256) return false;
738
+ const syncedHash = syncState[remoteModule.id];
739
+ return !syncedHash || localModule.contentSha256 != syncedHash;
740
+ });
741
+ if (!force && conflicting) {
742
+ throw createError(`本地模块“${conflicting.name}”与远端内容不同,继续拉取会覆盖本地数据`, 409, 'LOCAL_NEWER');
743
+ }
744
+
745
+ const downloadedModules = [];
746
+ for (const moduleItem of selectedRemoteModules) {
747
+ downloadedModules.push(await readRemoteModule(config, moduleItem));
748
+ }
749
+
750
+ const downloadedById = new Map(downloadedModules.map(moduleData => [moduleData.feature.id, moduleData]));
751
+ const selectedIds = new Set(selectedRemoteModules.map(moduleItem => moduleItem.id));
752
+ const latestLocalData = readLocalData();
753
+ const latestLocalModules = getLocalModules(latestLocalData);
754
+ const latestLocalById = new Map(latestLocalModules.map(moduleItem => [moduleItem.id, moduleItem]));
755
+ const changedDuringPull = selectedRemoteModules.find((remoteModule) => {
756
+ const before = localById.get(remoteModule.id);
757
+ const latest = latestLocalById.get(remoteModule.id);
758
+ return (before && !latest) || (!before && latest)
759
+ || (before && latest && before.contentSha256 != latest.contentSha256);
760
+ });
761
+ if (changedDuringPull) {
762
+ throw createError(`拉取期间本地模块“${changedDuringPull.name}”发生变化,请重新确认后再拉取`, 409, 'LOCAL_CHANGED');
763
+ }
764
+
765
+ const nextFeatures = latestLocalData.features.map((feature) => {
766
+ const downloaded = downloadedById.get(feature.id);
767
+ return downloaded ? downloaded.feature : feature;
768
+ });
769
+ downloadedModules.forEach((moduleData) => {
770
+ if (!latestLocalById.has(moduleData.feature.id)) nextFeatures.push(moduleData.feature);
771
+ });
772
+ const nextInterfaces = latestLocalData.interfaces
773
+ .filter(interfaceItem => !selectedIds.has(interfaceItem.featureId))
774
+ .concat(downloadedModules.flatMap(moduleData => moduleData.interfaces));
775
+ const nextData = validateSyncData({
776
+ version: SYNC_VERSION,
777
+ updatedAt: Date.now(),
778
+ features: nextFeatures,
779
+ interfaces: nextInterfaces,
780
+ });
781
+ writeLocalData(nextData);
782
+ saveSyncState(config, selectedRemoteModules);
783
+ return formatStatus(remote);
784
+ },
785
+ disconnect: () => {
786
+ fs.removeSync(configFile);
787
+ return formatStatus();
788
+ },
789
+ };
790
+ };
791
+
792
+ module.exports = {
793
+ WEBDAV_FILE_NAME,
794
+ LEGACY_COMPRESSED_WEBDAV_FILE_NAME,
795
+ LEGACY_WEBDAV_FILE_NAME,
796
+ DEFAULT_WEBDAV_ENDPOINT,
797
+ normalizeWebDavEndpoint,
798
+ getWebDavFileUrl,
799
+ getLegacyWebDavFileUrl,
800
+ getModuleFileUrl,
801
+ createModuleFileName,
802
+ validateManifest,
803
+ validateModuleData,
804
+ validateSyncData,
805
+ createWebDavSync,
806
+ };