@abtnode/util 1.16.45-beta-20250620-082630-c0c76051 → 1.16.45-beta-20250624-134945-a23c15fc

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.
@@ -0,0 +1,393 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+ const tar = require('tar');
4
+ const { types } = require('@ocap/mcrypto');
5
+ const { fromRandom } = require('@ocap/wallet');
6
+ const { update: updateMetaFile, read: readMetaFile } = require('@blocklet/meta/lib/file');
7
+ const {
8
+ BLOCKLET_GROUPS,
9
+ BLOCKLET_LATEST_SPEC_VERSION,
10
+ BLOCKLET_DEFAULT_VERSION,
11
+ BLOCKLET_META_FILE,
12
+ BLOCKLET_RELEASE_FOLDER_NAME,
13
+ BLOCKLET_BUNDLE_FOLDER_NAME,
14
+ BLOCKLET_RELEASE_FILE,
15
+ } = require('@blocklet/constant');
16
+ const { MAX_UPLOAD_FILE_SIZE } = require('@abtnode/constant');
17
+ const { zipToDir } = require('./zip');
18
+
19
+ const { createRelease } = require('./create-blocklet-release');
20
+
21
+ const TEMP_DIR_NAME = 'blocklet-upload-tmp';
22
+ const HTML_MIME_TYPE = 'text/html';
23
+ const GZIP_MIME_TYPE = 'application/gzip';
24
+ const ZIP_MIME_TYPE = 'application/zip';
25
+ const ALLOW_UPLOAD_MIME_TYPES = [HTML_MIME_TYPE, GZIP_MIME_TYPE, ZIP_MIME_TYPE];
26
+
27
+ const maxUploadSize = Number(process.env.MAX_UPLOAD_FILE_SIZE) || MAX_UPLOAD_FILE_SIZE;
28
+
29
+ const hasHtmlFile = (dir) => {
30
+ const files = ['index.html', 'index.htm'];
31
+ return files.some((file) => fs.existsSync(path.join(dir, file)));
32
+ };
33
+
34
+ /**
35
+ * 递归检查文件夹中是否存在指定文件
36
+ * 优先查找当前层级,再递归查找子目录,确保外层优先级最高
37
+ * @param {string} dir - 要检测的文件夹路径
38
+ * @param {string[]} filenames - 要查找的文件名数组
39
+ * @param {string} rootDir - 相对路径的根目录,用于计算相对路径
40
+ * @param {number} maxDepth - 最大搜索深度,默认为5
41
+ * @param {number} currentDepth - 当前递归深度,默认为0
42
+ * @returns {Promise<Object>} 返回找到的文件映射 {filename: relativePath}
43
+ */
44
+ const checkFileExist = async (dir, filenames = [], rootDir = null, maxDepth = 5, currentDepth = 0) => {
45
+ if (!dir || !filenames.length) return {};
46
+
47
+ const baseDir = rootDir || dir;
48
+ const result = {};
49
+
50
+ try {
51
+ // 读取当前目录内容
52
+ const files = await fs.readdir(dir);
53
+
54
+ // 串行获取文件统计信息,避免并行操作
55
+ const stats = [];
56
+ // eslint-disable-next-line no-await-in-loop
57
+ for (const file of files) {
58
+ const fullPath = path.join(dir, file);
59
+ // eslint-disable-next-line no-await-in-loop
60
+ const stat = await fs.stat(fullPath);
61
+ stats.push({ file, fullPath, stat });
62
+ }
63
+
64
+ // 优先在当前层级查找文件
65
+ for (const { file, fullPath, stat } of stats) {
66
+ if (!stat.isDirectory() && filenames.includes(file)) {
67
+ result[file] = path.relative(baseDir, fullPath);
68
+ }
69
+ }
70
+
71
+ // 对于未找到的文件,递归查找子目录(但不超过最大深度)
72
+ const remainingFiles = filenames.filter((name) => !result[name]);
73
+ if (remainingFiles.length > 0 && currentDepth < maxDepth) {
74
+ for (const { fullPath, stat } of stats) {
75
+ if (stat.isDirectory()) {
76
+ // 串行递归查找,确保操作顺序,传递深度信息
77
+ // eslint-disable-next-line no-await-in-loop
78
+ const found = await checkFileExist(fullPath, remainingFiles, baseDir, maxDepth, currentDepth + 1);
79
+ // 只添加当前结果中不存在的文件(保证外层优先级)
80
+ Object.keys(found).forEach((key) => {
81
+ if (!result[key]) {
82
+ result[key] = found[key];
83
+ }
84
+ });
85
+ }
86
+ }
87
+ }
88
+
89
+ return result;
90
+ } catch (error) {
91
+ return {};
92
+ }
93
+ };
94
+
95
+ /**
96
+ * 根据 index.html 的路径计算入口文件的根目录
97
+ * @param {string} filePath - index.html 文件的相对路径
98
+ * @returns {string} 入口文件的根目录路径
99
+ */
100
+ const getEntryFilePath = (filePath) => {
101
+ if (
102
+ !filePath ||
103
+ !filePath.startsWith(`${BLOCKLET_BUNDLE_FOLDER_NAME}/`) ||
104
+ !filePath.endsWith('index.html') ||
105
+ !filePath.endsWith('index.html')
106
+ ) {
107
+ return '.';
108
+ }
109
+
110
+ // 提取 bundle/ 后面的路径部分
111
+ const afterBundle = filePath.split(`${BLOCKLET_BUNDLE_FOLDER_NAME}/`)[1];
112
+ if (!afterBundle) {
113
+ return '.';
114
+ }
115
+
116
+ // 获取目录路径(去除文件名)
117
+ const dirPath = path.dirname(afterBundle);
118
+ return dirPath === '.' ? '.' : dirPath;
119
+ };
120
+
121
+ /**
122
+ * 规范化 bundle 目录结构
123
+ * 如果解压后只有一个文件夹且不是 bundle,则重命名为 bundle
124
+ * @param {string} tempDir - 临时目录
125
+ */
126
+ const normalizeBundleStructure = async (tempDir) => {
127
+ const allContents = await fs.readdir(tempDir);
128
+ // 过滤隐藏文件
129
+ const visibleContents = allContents.filter((item) => !item.startsWith('.'));
130
+
131
+ if (visibleContents.length === 1) {
132
+ const itemPath = path.join(tempDir, visibleContents[0]);
133
+ const stat = await fs.stat(itemPath);
134
+
135
+ if (stat.isDirectory() && visibleContents[0] !== BLOCKLET_BUNDLE_FOLDER_NAME) {
136
+ const bundlePath = path.join(tempDir, BLOCKLET_BUNDLE_FOLDER_NAME);
137
+ await fs.move(itemPath, bundlePath);
138
+ }
139
+ }
140
+ };
141
+
142
+ /**
143
+ * 处理压缩文件(.gz 或 .zip)
144
+ * @param {string} srcFile - 源文件路径
145
+ * @param {string} mimetype - 文件类型
146
+ * @param {string} tempDir - 临时目录
147
+ * @param {string} destDir - 目标目录
148
+ * @returns {Promise<string>} index.html 的相对路径
149
+ */
150
+ const handleArchiveFile = async (srcFile, mimetype, tempDir, destDir) => {
151
+ // 清空临时目录
152
+ await fs.emptyDir(tempDir);
153
+
154
+ try {
155
+ // 根据文件类型解压
156
+ if (mimetype === GZIP_MIME_TYPE) {
157
+ await tar.x({ file: srcFile, C: tempDir });
158
+ } else if (mimetype === ZIP_MIME_TYPE) {
159
+ await zipToDir(srcFile, tempDir);
160
+ }
161
+ } catch (err) {
162
+ throw new Error('Only zip or gz archives are supported as resources.');
163
+ }
164
+ // 处理解压后的目录结构
165
+ await normalizeBundleStructure(tempDir);
166
+
167
+ // 检查必要文件
168
+ const foundFiles = await checkFileExist(tempDir, ['blocklet.yml', 'index.html', 'index.htm']);
169
+ if (!foundFiles['blocklet.yml'] && !foundFiles['index.html'] && !foundFiles['index.htm']) {
170
+ throw new Error('Unable to parse the uploaded file, missing blocklet.yml or index.html or index.htm');
171
+ }
172
+
173
+ // 复制文件到目标目录
174
+ await fs.copy(tempDir, destDir);
175
+
176
+ // 如果不是 blocklet 项目,返回 index.html 路径
177
+ return foundFiles['blocklet.yml'] ? '' : foundFiles['index.html'] || foundFiles['index.htm'];
178
+ };
179
+
180
+ /**
181
+ * 处理 HTML 文件
182
+ * @param {string} srcFile - 源文件路径
183
+ * @param {string} destDir - 目标目录
184
+ */
185
+ const handleHtmlFile = async (srcFile, destDir) => {
186
+ const bundleDir = path.join(destDir, BLOCKLET_BUNDLE_FOLDER_NAME);
187
+ await fs.ensureDir(bundleDir);
188
+ await fs.copy(srcFile, path.join(bundleDir, 'index.html'));
189
+ };
190
+
191
+ /**
192
+ * 生成 blocklet 元数据文件
193
+ * @param {string} destDir - 目标目录
194
+ * @param {string} baseName - 基础名称
195
+ * @param {string} indexPath - index.html 路径
196
+ * @param {Object} user - 用户对象
197
+ */
198
+ const generateBlockletMeta = async (destDir, baseName, indexPath, user) => {
199
+ // 只有在需要时才生成元数据(HTML文件或有indexPath)
200
+ if (!indexPath && (await fs.pathExists(path.join(destDir, BLOCKLET_BUNDLE_FOLDER_NAME, BLOCKLET_META_FILE)))) {
201
+ return; // 已存在 blocklet.yml,无需生成
202
+ }
203
+
204
+ const randomWallet = fromRandom({ role: types.RoleType.ROLE_BLOCKLET });
205
+ const meta = {
206
+ name: randomWallet.address,
207
+ title: baseName.slice(0, 40),
208
+ description: `${baseName} (uploaded)`,
209
+ group: BLOCKLET_GROUPS[1], // static
210
+ publicUrl: '/',
211
+ main: !indexPath ? '.' : getEntryFilePath(indexPath),
212
+ author: user?.fullName || user?.email || 'Blocklet',
213
+ specVersion: BLOCKLET_LATEST_SPEC_VERSION,
214
+ version: BLOCKLET_DEFAULT_VERSION,
215
+ logo: 'logo.png',
216
+ did: randomWallet.address,
217
+ interfaces: [
218
+ {
219
+ type: 'web',
220
+ name: 'publicUrl',
221
+ path: '/',
222
+ prefix: '*',
223
+ port: 'BLOCKLET_PORT',
224
+ protocol: 'http',
225
+ proxyBehavior: 'service',
226
+ },
227
+ ],
228
+ };
229
+
230
+ const metaPath = path.join(destDir, BLOCKLET_BUNDLE_FOLDER_NAME, BLOCKLET_META_FILE);
231
+ await updateMetaFile(metaPath, meta);
232
+ };
233
+
234
+ /**
235
+ * 清理临时文件
236
+ * @param {string} srcFile - 源文件路径
237
+ * @param {string} tempDir - 临时目录路径
238
+ */
239
+ const cleanup = async (srcFile, tempDir) => {
240
+ try {
241
+ // 串行删除,确保操作完成
242
+ if (await fs.pathExists(srcFile)) {
243
+ await fs.remove(srcFile);
244
+ }
245
+ if (await fs.pathExists(tempDir)) {
246
+ await fs.emptyDir(tempDir);
247
+ }
248
+ } catch (error) {
249
+ // 清理失败不影响主流程,只记录错误
250
+ console.warn('Failed to clean up temporary files:', error.message);
251
+ }
252
+ };
253
+
254
+ /**
255
+ * 处理文件上传的核心逻辑
256
+ * @param {Object} node - 节点对象
257
+ * @param {Object} uploadMetadata - 上传文件的元数据
258
+ * @param {Object} blocklet - Blocklet 对象
259
+ * @param {Object} user - 用户对象
260
+ * @returns {Promise<Object>} 处理结果
261
+ */
262
+ const onUploadComponent = async (node, uploadMetadata, blocklet, user) => {
263
+ const {
264
+ id: filename,
265
+ size,
266
+ metadata: { filename: originalname, filetype: mimetype },
267
+ } = uploadMetadata;
268
+
269
+ const srcFile = path.join(node.dataDirs.tmp, filename);
270
+
271
+ // 验证文件存在性
272
+ if (!(await fs.pathExists(srcFile))) {
273
+ throw new Error(`File not found: ${filename}`);
274
+ }
275
+
276
+ // 验证文件类型
277
+ if (!ALLOW_UPLOAD_MIME_TYPES.includes(mimetype)) {
278
+ throw new Error(`Unsupported file type: ${mimetype}`);
279
+ }
280
+
281
+ // 验证文件大小
282
+ if (size > maxUploadSize * 1024 * 1024) {
283
+ throw new Error(`File size exceeds the limit: ${maxUploadSize}MB`);
284
+ }
285
+
286
+ // 初始化目录结构
287
+ const tempDir = path.join(node.dataDirs.tmp, TEMP_DIR_NAME);
288
+ const baseName = path.parse(originalname).name;
289
+ const destDir = path.join(blocklet.env.dataDir, 'components', baseName);
290
+
291
+ await fs.ensureDir(tempDir);
292
+ await fs.ensureDir(destDir);
293
+
294
+ let indexPath = '';
295
+
296
+ try {
297
+ // 根据文件类型处理解压或复制
298
+ if (mimetype === GZIP_MIME_TYPE || mimetype === ZIP_MIME_TYPE) {
299
+ indexPath = await handleArchiveFile(srcFile, mimetype, tempDir, destDir);
300
+ } else if (mimetype === HTML_MIME_TYPE) {
301
+ await handleHtmlFile(srcFile, destDir);
302
+ }
303
+
304
+ // 生成 blocklet 元数据
305
+ await generateBlockletMeta(destDir, baseName, indexPath, user);
306
+
307
+ // 创建发布包
308
+ const release = await createRelease(destDir);
309
+
310
+ // 清理临时文件
311
+ // FIXME: 我们使用的上传组件,在上传完成后会验证上传文件是否存在,如果立即清理会在前端显示上传失败
312
+ // 所以这里需要有一点延迟清理
313
+ setTimeout(() => {
314
+ cleanup(srcFile, tempDir);
315
+ }, 5000);
316
+
317
+ const releaseDir = path.join(destDir, BLOCKLET_RELEASE_FOLDER_NAME);
318
+ return {
319
+ filename,
320
+ size,
321
+ originalname,
322
+ mimetype,
323
+ meta: release.meta,
324
+ inputUrl: `file://${path.join(releaseDir, BLOCKLET_RELEASE_FILE)}`,
325
+ };
326
+ } catch (error) {
327
+ // 出错时清理临时文件
328
+ cleanup(srcFile, tempDir);
329
+ throw error;
330
+ }
331
+ };
332
+
333
+ /**
334
+ * 更新组件的 DID,用于覆盖已上传的内容
335
+ * @param {string} url - 安装包地址(file:// 开头的 blocklet.json 文件地址)
336
+ * @param {string} did - 要更新的 DID
337
+ * @returns {Promise<Object>} 更新后的元数据
338
+ */
339
+ const updateComponentDid = async (url, did) => {
340
+ if (!url.startsWith('file://')) {
341
+ throw new Error('URL must start with file://');
342
+ }
343
+
344
+ // 根据 blocklet.json 路径定位 bundle 目录
345
+ const releaseDir = path.dirname(url.replace('file://', ''));
346
+ const bundlePath = path.join(releaseDir, '..', BLOCKLET_BUNDLE_FOLDER_NAME);
347
+ const blockletYmlPath = path.join(bundlePath, BLOCKLET_META_FILE);
348
+
349
+ // 串行执行:读取 -> 更新 -> 写入 -> 重新打包
350
+ const existingMeta = await readMetaFile(blockletYmlPath);
351
+ if (existingMeta.did !== did) {
352
+ existingMeta.did = did;
353
+ await updateMetaFile(blockletYmlPath, existingMeta);
354
+
355
+ // 重新创建发布包
356
+ const release = await createRelease(path.join(bundlePath, '..'));
357
+ return release.meta;
358
+ }
359
+ return existingMeta;
360
+ };
361
+
362
+ /**
363
+ * 清空上传的文件
364
+ * @param {string} url - 安装包地址(file:// 开头的 blocklet.json 文件地址)
365
+ * @param {string} tempDir - 临时目录路径
366
+ */
367
+ const removeUploadFile = async (url) => {
368
+ if (!url.startsWith('file://')) {
369
+ throw new Error('URL must start with file://');
370
+ }
371
+
372
+ try {
373
+ const releaseDir = path.dirname(url.replace('file://', ''));
374
+ // 获取上传的文件夹
375
+ const componentDir = path.dirname(releaseDir, '..');
376
+ // 判断是否有这个文件夹,如果有在进行删除
377
+ if (await fs.pathExists(componentDir)) {
378
+ await fs.remove(componentDir);
379
+ }
380
+ } catch (error) {
381
+ console.error('removeUploadFile failed', { error });
382
+ throw error;
383
+ }
384
+ };
385
+
386
+ module.exports = {
387
+ checkFileExist,
388
+ TEMP_DIR_NAME,
389
+ onUploadComponent,
390
+ updateComponentDid,
391
+ removeUploadFile,
392
+ hasHtmlFile,
393
+ };
package/lib/zip.js ADDED
@@ -0,0 +1,43 @@
1
+ const archiver = require('archiver');
2
+ const { ensureDirSync, existsSync, removeSync, createWriteStream } = require('fs-extra');
3
+ const { dirname } = require('path');
4
+ const StreamZip = require('node-stream-zip');
5
+
6
+ /**
7
+ *
8
+ *
9
+ * @param {string} source ~/abc/ 通常是一个文件夹
10
+ * @param {string} target abc.zip 压缩包的名称
11
+ * @return {*}
12
+ */
13
+ function dirToZip(source, target) {
14
+ return new Promise((resolve, reject) => {
15
+ ensureDirSync(dirname(target));
16
+
17
+ if (existsSync(target)) {
18
+ removeSync(target);
19
+ }
20
+
21
+ const output = createWriteStream(target);
22
+ const archive = archiver('zip', { level: 9 });
23
+ archive.on('error', (err) => reject(err));
24
+ output.on('close', () => resolve());
25
+
26
+ archive.directory(source, false);
27
+
28
+ archive.pipe(output);
29
+ archive.finalize();
30
+ });
31
+ }
32
+
33
+ async function zipToDir(source, target) {
34
+ // eslint-disable-next-line new-cap
35
+ const zip = new StreamZip.async({ file: source });
36
+ await zip.extract(null, target);
37
+ await zip.close();
38
+ }
39
+
40
+ module.exports = {
41
+ dirToZip,
42
+ zipToDir,
43
+ };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.16.45-beta-20250620-082630-c0c76051",
6
+ "version": "1.16.45-beta-20250624-134945-a23c15fc",
7
7
  "description": "ArcBlock's JavaScript utility",
8
8
  "main": "lib/index.js",
9
9
  "files": [
@@ -18,13 +18,13 @@
18
18
  "author": "polunzh <polunzh@gmail.com> (http://github.com/polunzh)",
19
19
  "license": "Apache-2.0",
20
20
  "dependencies": {
21
- "@abtnode/constant": "1.16.45-beta-20250620-082630-c0c76051",
22
- "@abtnode/db-cache": "1.16.45-beta-20250620-082630-c0c76051",
21
+ "@abtnode/constant": "1.16.45-beta-20250624-134945-a23c15fc",
22
+ "@abtnode/db-cache": "1.16.45-beta-20250624-134945-a23c15fc",
23
23
  "@arcblock/did": "1.20.14",
24
24
  "@arcblock/pm2": "^5.4.0",
25
- "@blocklet/constant": "1.16.45-beta-20250620-082630-c0c76051",
25
+ "@blocklet/constant": "1.16.45-beta-20250624-134945-a23c15fc",
26
26
  "@blocklet/error": "^0.2.5",
27
- "@blocklet/meta": "1.16.45-beta-20250620-082630-c0c76051",
27
+ "@blocklet/meta": "1.16.45-beta-20250624-134945-a23c15fc",
28
28
  "@blocklet/xss": "^0.1.36",
29
29
  "@ocap/client": "1.20.14",
30
30
  "@ocap/mcrypto": "1.20.14",
@@ -58,6 +58,7 @@
58
58
  "lodash": "^4.17.21",
59
59
  "minimatch": "^9.0.4",
60
60
  "multiformats": "9.9.0",
61
+ "node-stream-zip": "^1.15.0",
61
62
  "npm-packlist": "^7.0.4",
62
63
  "p-retry": "^4.6.2",
63
64
  "p-wait-for": "^3.2.0",
@@ -88,5 +89,5 @@
88
89
  "fs-extra": "^11.2.0",
89
90
  "jest": "^29.7.0"
90
91
  },
91
- "gitHead": "70151f84be54392ce491a1f40976e533286d06b8"
92
+ "gitHead": "3b56a1dbe3fea9df0d6b644a1821afae9b376ee8"
92
93
  }