@abtnode/core 1.17.10 → 1.17.11-beta-20260225-043848-68611a07
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.
- package/lib/blocklet/manager/disk/env-config-manager.js +5 -0
- package/lib/blocklet/manager/disk/install-core-manager.js +8 -6
- package/lib/blocklet/manager/disk.js +24 -0
- package/lib/blocklet/manager/helper/install-application-from-backup.js +3 -1
- package/lib/blocklet/migration-dist/migration.cjs +1 -1
- package/lib/blocklet/storage/export/blocklet-export.js +29 -0
- package/lib/blocklet/storage/export/blocklet-extras-export.js +27 -0
- package/lib/blocklet/storage/export/blocklets-export.js +50 -0
- package/lib/blocklet/storage/export/export-meta.js +34 -0
- package/lib/blocklet/storage/export/exporter.js +140 -0
- package/lib/blocklet/storage/import/importer.js +110 -0
- package/lib/event/index.js +28 -0
- package/lib/index.js +1 -0
- package/lib/router/helper.js +7 -2
- package/lib/router/index.js +11 -1
- package/lib/router/security/limiter.js +20 -0
- package/lib/states/blocklet-extras.js +12 -0
- package/package.json +22 -22
|
@@ -184,6 +184,11 @@ async function config(manager, { did, configs: newConfigs, skipHook, skipDidDocu
|
|
|
184
184
|
manager.emit(BlockletEvents.spaceConnected, blocklet);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// Reload nginx when APP_NO_INDEX changes to update X-Robots-Tag header
|
|
188
|
+
if (finalConfigs.find((x) => x.key === 'APP_NO_INDEX')) {
|
|
189
|
+
manager.emit(BlockletEvents.gatewayConfigChanged, blocklet);
|
|
190
|
+
}
|
|
191
|
+
|
|
187
192
|
// update blocklet meta
|
|
188
193
|
if (blocklet.structVersion && !childDid) {
|
|
189
194
|
const changedTitle = finalConfigs.find((x) => x.key === BLOCKLET_CONFIGURABLE_KEY.BLOCKLET_APP_NAME)?.value;
|
|
@@ -98,14 +98,16 @@ async function _installBlocklet(manager, { did, oldBlocklet, componentDids, cont
|
|
|
98
98
|
// Add initialize authentication settings
|
|
99
99
|
await manager.migrateBlockletAuthentication({ did });
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
if (!context.skipHooks) {
|
|
102
|
+
// pre install
|
|
103
|
+
await manager._runUserHook('preInstall', blocklet, context);
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
// post install
|
|
106
|
+
await manager._runUserHook('postInstall', blocklet, context);
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// pre flight
|
|
109
|
+
await manager._runUserHook('preFlight', blocklet, context);
|
|
110
|
+
}
|
|
109
111
|
|
|
110
112
|
await states.blocklet.setInstalledAt(did);
|
|
111
113
|
|
|
@@ -38,6 +38,7 @@ const states = require('../../states');
|
|
|
38
38
|
const BaseBlockletManager = require('./base');
|
|
39
39
|
const { BlockletRuntimeMonitor } = require('../../monitor/blocklet-runtime-monitor');
|
|
40
40
|
const { SpacesBackup } = require('../storage/backup/spaces');
|
|
41
|
+
const { BlockletImporter } = require('../storage/import/importer');
|
|
41
42
|
const { installApplicationFromDev } = require('./helper/install-application-from-dev');
|
|
42
43
|
const { installComponentFromDev } = require('./helper/install-component-from-dev');
|
|
43
44
|
const { diff } = require('./helper/install-component-from-upload');
|
|
@@ -449,6 +450,29 @@ class DiskBlockletManager extends BaseBlockletManager {
|
|
|
449
450
|
throw new Error('Can only restore from spaces or disk');
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Import a blocklet from an export directory
|
|
455
|
+
* @param {{ inputDir: string, overwrite?: boolean }} input
|
|
456
|
+
* @param {Record<string, any>} context
|
|
457
|
+
*/
|
|
458
|
+
async importBlocklet(input, context = {}) {
|
|
459
|
+
if (input.overwrite && input.blockletDid) {
|
|
460
|
+
const existing = await this.getBlocklet(input.blockletDid);
|
|
461
|
+
if (existing) {
|
|
462
|
+
await this.delete({ did: input.blockletDid, keepData: false, keepLogsDir: false, keepConfigs: false }, context);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const importer = new BlockletImporter({
|
|
467
|
+
inputDir: input.inputDir,
|
|
468
|
+
manager: this,
|
|
469
|
+
states,
|
|
470
|
+
context,
|
|
471
|
+
progressCallback: input.progressCallback,
|
|
472
|
+
});
|
|
473
|
+
return importer.import();
|
|
474
|
+
}
|
|
475
|
+
|
|
452
476
|
/**
|
|
453
477
|
*
|
|
454
478
|
* @param {import('@blocklet/server-js').RequestBlockletInput} param0
|
|
@@ -47,7 +47,9 @@ const installApplicationFromBackup = async ({
|
|
|
47
47
|
throw new Error(`dir(${dir}) does not exist`);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
context.startImmediately
|
|
50
|
+
if (context.startImmediately === undefined) {
|
|
51
|
+
context.startImmediately = true;
|
|
52
|
+
}
|
|
51
53
|
|
|
52
54
|
// parse data from source dir
|
|
53
55
|
const srcBundleDirs = await getAppDirs(path.join(dir, 'blocklets'));
|
|
@@ -39100,7 +39100,7 @@ exports.isSystemRole = isSystemRole;
|
|
|
39100
39100
|
/***/ ((module) => {
|
|
39101
39101
|
|
|
39102
39102
|
"use strict";
|
|
39103
|
-
module.exports = /*#__PURE__*/JSON.parse('{"name":"@abtnode/core","publishConfig":{"access":"public"},"version":"1.17.
|
|
39103
|
+
module.exports = /*#__PURE__*/JSON.parse('{"name":"@abtnode/core","publishConfig":{"access":"public"},"version":"1.17.10","description":"","main":"lib/index.js","files":["lib"],"scripts":{"lint":"eslint tests lib --ignore-pattern \'tests/assets/*\'","lint:fix":"eslint --fix tests lib"},"keywords":[],"author":"wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)","license":"Apache-2.0","dependencies":{"@abtnode/analytics":"1.17.10","@abtnode/auth":"1.17.10","@abtnode/certificate-manager":"1.17.10","@abtnode/constant":"1.17.10","@abtnode/cron":"1.17.10","@abtnode/db-cache":"1.17.10","@abtnode/docker-utils":"1.17.10","@abtnode/logger":"1.17.10","@abtnode/models":"1.17.10","@abtnode/queue":"1.17.10","@abtnode/rbac":"1.17.10","@abtnode/router-provider":"1.17.10","@abtnode/util":"1.17.10","@arcblock/did":"1.29.8","@arcblock/did-connect-js":"1.29.8","@arcblock/did-ext":"1.29.8","@arcblock/did-motif":"^1.1.14","@arcblock/did-util":"1.29.8","@arcblock/event-hub":"1.29.8","@arcblock/jwt":"1.29.8","@arcblock/pm2-events":"^0.0.5","@arcblock/validator":"1.29.8","@arcblock/vc":"1.29.8","@blocklet/constant":"1.17.10","@blocklet/did-space-js":"^1.2.16","@blocklet/env":"1.17.10","@blocklet/error":"^0.3.5","@blocklet/meta":"1.17.10","@blocklet/resolver":"1.17.10","@blocklet/sdk":"1.17.10","@blocklet/server-js":"1.17.10","@blocklet/store":"1.17.10","@blocklet/theme":"^3.5.2","@fidm/x509":"^1.2.1","@ocap/mcrypto":"1.29.8","@ocap/util":"1.29.8","@ocap/wallet":"1.29.8","@slack/webhook":"^7.0.6","archiver":"^7.0.1","axios":"^1.7.9","axon":"^2.0.3","chalk":"^4.1.2","cross-spawn":"^7.0.3","dayjs":"^1.11.13","deep-diff":"^1.0.2","detect-port":"^1.5.1","envfile":"^7.1.0","escape-string-regexp":"^4.0.0","fast-glob":"^3.3.2","filesize":"^10.1.1","flat":"^5.0.2","fs-extra":"^11.2.0","get-port":"^5.1.1","hasha":"^5.2.2","is-base64":"^1.1.0","is-cidr":"4","is-ip":"3","is-url":"^1.2.4","joi":"17.12.2","joi-extension-semver":"^5.0.0","js-yaml":"^4.1.0","kill-port":"^2.0.1","lodash":"^4.17.21","node-stream-zip":"^1.15.0","p-all":"^3.0.0","p-limit":"^3.1.0","p-map":"^4.0.0","p-retry":"^4.6.2","p-wait-for":"^3.2.0","private-ip":"^2.3.4","rate-limiter-flexible":"^5.0.5","read-last-lines":"^1.8.0","semver":"^7.6.3","sequelize":"^6.35.0","shelljs":"^0.8.5","slugify":"^1.6.6","ssri":"^8.0.1","stream-throttle":"^0.1.3","stream-to-promise":"^3.0.0","systeminformation":"^5.23.3","tail":"^2.2.4","tar":"^6.1.11","transliteration":"2.3.5","ua-parser-js":"^1.0.2","ufo":"^1.5.3","uuid":"^11.1.0","valid-url":"^1.0.9","which":"^2.0.2","xbytes":"^1.8.0"},"devDependencies":{"axios-mock-adapter":"^2.1.0","expand-tilde":"^2.0.2","express":"^4.18.2","unzipper":"^0.10.11"},"gitHead":"e5764f753181ed6a7c615cd4fc6682aacf0cb7cd"}');
|
|
39104
39104
|
|
|
39105
39105
|
/***/ }),
|
|
39106
39106
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { BlockletBackup } = require('../backup/blocklet');
|
|
2
|
+
|
|
3
|
+
class BlockletExport extends BlockletBackup {
|
|
4
|
+
ensureParams(backup) {
|
|
5
|
+
super.ensureParams(backup);
|
|
6
|
+
// Capture the wallet-derived appDid from the exporter
|
|
7
|
+
this.exportAppDid = backup.appDid;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
cleanData() {
|
|
11
|
+
const clone = super.cleanData();
|
|
12
|
+
// Update appDid/appPid to match wallet address derived from appSk
|
|
13
|
+
// Without this, blocklet.json would have appDid = meta.did, which differs
|
|
14
|
+
// from the wallet address that installApplicationFromBackup expects
|
|
15
|
+
if (this.exportAppDid) {
|
|
16
|
+
clone.appDid = this.exportAppDid;
|
|
17
|
+
clone.appPid = this.exportAppDid;
|
|
18
|
+
}
|
|
19
|
+
return clone;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// No-op: keep migratedFrom.appSk as plaintext for cross-server export
|
|
23
|
+
// eslint-disable-next-line class-methods-use-this
|
|
24
|
+
encrypt(info) {
|
|
25
|
+
return info;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { BlockletExport };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { readFileSync } = require('fs-extra');
|
|
2
|
+
const isEmpty = require('lodash/isEmpty');
|
|
3
|
+
const { join } = require('path');
|
|
4
|
+
const security = require('@abtnode/util/lib/security');
|
|
5
|
+
|
|
6
|
+
const { BlockletExtrasBackup } = require('../backup/blocklet-extras');
|
|
7
|
+
|
|
8
|
+
class BlockletExtrasExport extends BlockletExtrasBackup {
|
|
9
|
+
/**
|
|
10
|
+
* Decrypt secure configs to plaintext using .sock key.
|
|
11
|
+
* Keep config.secure = true so that import-side encryptSecurityData() re-encrypts correctly.
|
|
12
|
+
*/
|
|
13
|
+
encrypt(configs) {
|
|
14
|
+
if (isEmpty(configs)) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const dk = readFileSync(join(this.serverDir, '.sock'));
|
|
19
|
+
for (const config of configs) {
|
|
20
|
+
if (config.secure) {
|
|
21
|
+
config.value = security.decrypt(config.value, this.blocklet.meta.did, dk);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { BlockletExtrasExport };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const { join } = require('path');
|
|
3
|
+
const { BlockletsBackup } = require('../backup/blocklets');
|
|
4
|
+
const { getBundleDir } = require('../../../util/blocklet');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Export ALL component bundles (including registry children).
|
|
8
|
+
* Unlike BlockletsBackup which filters out HTTP-URL bundles,
|
|
9
|
+
* this exports everything so the import doesn't depend on the store.
|
|
10
|
+
*/
|
|
11
|
+
class BlockletsExport extends BlockletsBackup {
|
|
12
|
+
async export() {
|
|
13
|
+
const metas = this._getAllComponentMetas(this.blocklet);
|
|
14
|
+
const serverBlockletsDir = join(this.serverDir, 'blocklets');
|
|
15
|
+
|
|
16
|
+
/** @type {import('../backup/blocklets').DirToZipMeta[]} */
|
|
17
|
+
const dirs = [];
|
|
18
|
+
for (const meta of metas) {
|
|
19
|
+
const sourceDir = getBundleDir(serverBlockletsDir, meta);
|
|
20
|
+
if (!fs.existsSync(sourceDir)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const zipPath = join(this.backupDir, 'blocklets', meta.bundleName || meta.name, `${meta.version}.zip`);
|
|
24
|
+
dirs.push({ sourceDir, zipPath });
|
|
25
|
+
}
|
|
26
|
+
await this.dirsToZip(dirs);
|
|
27
|
+
|
|
28
|
+
return { dirs };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Collect full meta objects for all components (parent + children, recursive).
|
|
33
|
+
* Returns the meta object directly so getBundleDir can compute the correct path.
|
|
34
|
+
*/
|
|
35
|
+
_getAllComponentMetas(blocklet) {
|
|
36
|
+
if (!blocklet?.meta?.bundleName || !blocklet?.meta?.version) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const metas = [blocklet.meta];
|
|
41
|
+
|
|
42
|
+
for (const child of blocklet.children || []) {
|
|
43
|
+
metas.push(...this._getAllComponentMetas(child));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return metas;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { BlockletsExport };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const { outputJson } = require('fs-extra');
|
|
2
|
+
const { join } = require('path');
|
|
3
|
+
|
|
4
|
+
const EXPORT_META_VERSION = 1;
|
|
5
|
+
const EXPORT_META_FILENAME = 'export-meta.json';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} params
|
|
9
|
+
* @param {string} params.outDir - export output directory
|
|
10
|
+
* @param {object} params.blocklet - blocklet state
|
|
11
|
+
* @param {string} params.serverDid - source server DID
|
|
12
|
+
* @param {string} params.appSk - plaintext application secret key
|
|
13
|
+
* @param {string} params.appDid - application DID
|
|
14
|
+
* @returns {Promise<object>} the written meta object
|
|
15
|
+
*/
|
|
16
|
+
async function writeExportMeta({ outDir, blocklet, serverDid, appSk, appDid }) {
|
|
17
|
+
const meta = {
|
|
18
|
+
version: EXPORT_META_VERSION,
|
|
19
|
+
exportedAt: new Date().toISOString(),
|
|
20
|
+
sourceServerDid: serverDid,
|
|
21
|
+
blockletDid: blocklet.meta.did,
|
|
22
|
+
blockletName: blocklet.meta.name,
|
|
23
|
+
blockletTitle: blocklet.meta.title,
|
|
24
|
+
blockletVersion: blocklet.meta.version,
|
|
25
|
+
structVersion: blocklet.structVersion,
|
|
26
|
+
appSk,
|
|
27
|
+
appDid,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
await outputJson(join(outDir, EXPORT_META_FILENAME), meta, { spaces: 2 });
|
|
31
|
+
return meta;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { writeExportMeta, EXPORT_META_VERSION, EXPORT_META_FILENAME };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { isValid } = require('@arcblock/did');
|
|
3
|
+
const { ensureDirSync } = require('fs-extra');
|
|
4
|
+
const isEmpty = require('lodash/isEmpty');
|
|
5
|
+
const { getBlockletInfo } = require('@blocklet/meta/lib/info');
|
|
6
|
+
const logger = require('@abtnode/logger')('@abtnode/core:storage:export');
|
|
7
|
+
|
|
8
|
+
const states = require('../../../states');
|
|
9
|
+
const { BlockletExport } = require('./blocklet-export');
|
|
10
|
+
const { BlockletExtrasExport } = require('./blocklet-extras-export');
|
|
11
|
+
const { BlockletsExport } = require('./blocklets-export');
|
|
12
|
+
const { DataBackup } = require('../backup/data');
|
|
13
|
+
const { RoutingRuleBackup } = require('../backup/routing-rule');
|
|
14
|
+
const { LogsBackup } = require('../backup/logs');
|
|
15
|
+
const { writeExportMeta } = require('./export-meta');
|
|
16
|
+
const { dockerExecChown } = require('../../../util/docker/docker-exec-chown');
|
|
17
|
+
const checkDockerRunHistory = require('../../../util/docker/check-docker-run-history');
|
|
18
|
+
const { dockerBackupPgBlockletDb } = require('../../../util/docker/docker-backup-pg-blocklet-db');
|
|
19
|
+
|
|
20
|
+
class BlockletExporter {
|
|
21
|
+
constructor({ appDid, outDir, serverDir, options = {}, progressCallback } = {}) {
|
|
22
|
+
if (isEmpty(appDid) || !isValid(appDid)) {
|
|
23
|
+
throw new Error(`appDid(${appDid}) is not a valid did`);
|
|
24
|
+
}
|
|
25
|
+
if (isEmpty(outDir)) {
|
|
26
|
+
throw new Error('outDir is required');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.appDid = appDid;
|
|
30
|
+
this.outDir = outDir;
|
|
31
|
+
this._serverDir = serverDir;
|
|
32
|
+
this.includeLogs = options.includeLogs || false;
|
|
33
|
+
this.progressCallback = progressCallback || (() => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async initialize() {
|
|
37
|
+
this.blocklet = await states.blocklet.getBlocklet(this.appDid);
|
|
38
|
+
if (isEmpty(this.blocklet)) {
|
|
39
|
+
throw new Error(`Blocklet ${this.appDid} not found`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.serverDir = this._serverDir || process.env.ABT_NODE_DATA_DIR;
|
|
43
|
+
this.backupDir = this.outDir;
|
|
44
|
+
ensureDirSync(this.backupDir);
|
|
45
|
+
|
|
46
|
+
// Extract appSk from blocklet wallet
|
|
47
|
+
const nodeInfo = await states.node.read();
|
|
48
|
+
const { wallet } = getBlockletInfo(this.blocklet, nodeInfo.sk);
|
|
49
|
+
this.appSk = wallet.secretKey;
|
|
50
|
+
this.appDid = wallet.address;
|
|
51
|
+
this.serverDid = nodeInfo.did;
|
|
52
|
+
|
|
53
|
+
logger.info('export initialized', { appDid: this.appDid, outDir: this.outDir });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async prepareDocker() {
|
|
57
|
+
const nodeInfo = await states.node.read();
|
|
58
|
+
|
|
59
|
+
if (checkDockerRunHistory(nodeInfo)) {
|
|
60
|
+
let paths = [];
|
|
61
|
+
if (this.blocklet) {
|
|
62
|
+
paths = this.blocklet.children.map((child) => {
|
|
63
|
+
if (!child.meta?.docker?.image) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return path.join(this.serverDir, 'blocklets', child.meta.name, child.meta.version);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
await dockerExecChown({
|
|
70
|
+
name: `${this.blocklet.meta.did}-export`,
|
|
71
|
+
dirs: [path.join(this.serverDir, 'data', this.blocklet.meta.did), ...paths.filter(Boolean)],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async backupPostgres() {
|
|
77
|
+
if (!this.blocklet) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const dataDir =
|
|
81
|
+
this.blocklet.environments?.find((v) => v.key === 'BLOCKLET_APP_DATA_DIR')?.value ||
|
|
82
|
+
path.join(this.serverDir, 'data', this.blocklet.appPid || this.blocklet.appDid);
|
|
83
|
+
const dbPath = path.join(dataDir, 'blocklet.db');
|
|
84
|
+
await dockerBackupPgBlockletDb(dbPath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async export() {
|
|
88
|
+
await this.initialize();
|
|
89
|
+
this.progressCallback('Preparing Docker environment...');
|
|
90
|
+
|
|
91
|
+
await this.prepareDocker();
|
|
92
|
+
await this.backupPostgres();
|
|
93
|
+
|
|
94
|
+
const input = { appDid: this.appDid };
|
|
95
|
+
|
|
96
|
+
// Data must be exported first
|
|
97
|
+
this.progressCallback('Exporting data directory...');
|
|
98
|
+
const dataBackup = new DataBackup(input);
|
|
99
|
+
dataBackup.ensureParams(this);
|
|
100
|
+
await dataBackup.export();
|
|
101
|
+
|
|
102
|
+
// Export blocklet state, extras, bundles, and routing rules in parallel
|
|
103
|
+
this.progressCallback('Exporting blocklet state and bundles...');
|
|
104
|
+
const storages = [
|
|
105
|
+
new BlockletExport(input),
|
|
106
|
+
new BlockletsExport(input),
|
|
107
|
+
new BlockletExtrasExport(input),
|
|
108
|
+
new RoutingRuleBackup(input),
|
|
109
|
+
];
|
|
110
|
+
await Promise.all(
|
|
111
|
+
storages.map((storage) => {
|
|
112
|
+
storage.ensureParams(this);
|
|
113
|
+
return storage.export();
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Optional: export logs
|
|
118
|
+
if (this.includeLogs) {
|
|
119
|
+
this.progressCallback('Exporting logs...');
|
|
120
|
+
const logsBackup = new LogsBackup(input);
|
|
121
|
+
logsBackup.ensureParams(this);
|
|
122
|
+
await logsBackup.export();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Write export metadata
|
|
126
|
+
this.progressCallback('Writing export metadata...');
|
|
127
|
+
const meta = await writeExportMeta({
|
|
128
|
+
outDir: this.backupDir,
|
|
129
|
+
blocklet: this.blocklet,
|
|
130
|
+
serverDid: this.serverDid,
|
|
131
|
+
appSk: this.appSk,
|
|
132
|
+
appDid: this.appDid,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
logger.info('export completed', { appDid: this.appDid, outDir: this.outDir });
|
|
136
|
+
return meta;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { BlockletExporter };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const { join } = require('path');
|
|
3
|
+
const fg = require('fast-glob');
|
|
4
|
+
const logger = require('@abtnode/logger')('@abtnode/core:storage:import');
|
|
5
|
+
|
|
6
|
+
const { EXPORT_META_FILENAME } = require('../export/export-meta');
|
|
7
|
+
const { installApplicationFromBackup } = require('../../manager/helper/install-application-from-backup');
|
|
8
|
+
const { zipToDir } = require('../utils/zip');
|
|
9
|
+
|
|
10
|
+
class BlockletImporter {
|
|
11
|
+
constructor({ inputDir, manager, states, context = {}, progressCallback } = {}) {
|
|
12
|
+
if (!inputDir || !fs.existsSync(inputDir)) {
|
|
13
|
+
throw new Error(`Input directory does not exist: ${inputDir}`);
|
|
14
|
+
}
|
|
15
|
+
this.inputDir = inputDir;
|
|
16
|
+
this.manager = manager;
|
|
17
|
+
this.states = states;
|
|
18
|
+
this.context = context;
|
|
19
|
+
this.progressCallback = progressCallback || (() => {});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
readExportMeta() {
|
|
23
|
+
const metaPath = join(this.inputDir, EXPORT_META_FILENAME);
|
|
24
|
+
if (!fs.existsSync(metaPath)) {
|
|
25
|
+
throw new Error(`Export metadata not found: ${metaPath}`);
|
|
26
|
+
}
|
|
27
|
+
return fs.readJSONSync(metaPath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
validate(meta) {
|
|
31
|
+
if (!meta.version) {
|
|
32
|
+
throw new Error('Invalid export metadata: missing version');
|
|
33
|
+
}
|
|
34
|
+
if (!meta.appSk) {
|
|
35
|
+
throw new Error('Invalid export metadata: missing appSk');
|
|
36
|
+
}
|
|
37
|
+
if (!meta.appDid) {
|
|
38
|
+
throw new Error('Invalid export metadata: missing appDid');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check required files exist
|
|
42
|
+
const requiredFiles = ['blocklet.json', 'blocklet-extras.json', 'routing_rule.json'];
|
|
43
|
+
for (const file of requiredFiles) {
|
|
44
|
+
if (!fs.existsSync(join(this.inputDir, file))) {
|
|
45
|
+
throw new Error(`Required file missing in export directory: ${file}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract bundle zip files so installApplicationFromBackup can find them.
|
|
52
|
+
* Similar to BlockletsRestore.import() but keeps the zip files intact.
|
|
53
|
+
*/
|
|
54
|
+
async extractBundleZips() {
|
|
55
|
+
const blockletsDir = join(this.inputDir, 'blocklets');
|
|
56
|
+
if (!fs.existsSync(blockletsDir)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Only match bundle zips at depth 1 (<name>/<version>.zip),
|
|
61
|
+
// not nested zips inside already-extracted bundle directories
|
|
62
|
+
const paths = await fg('*/*.zip', {
|
|
63
|
+
cwd: blockletsDir,
|
|
64
|
+
onlyFiles: true,
|
|
65
|
+
absolute: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await Promise.all(
|
|
69
|
+
paths.map(async (zipPath) => {
|
|
70
|
+
const target = zipPath.replace(/.zip$/, '');
|
|
71
|
+
if (!fs.existsSync(target)) {
|
|
72
|
+
fs.ensureDirSync(target);
|
|
73
|
+
}
|
|
74
|
+
await zipToDir(zipPath, target);
|
|
75
|
+
logger.info('extracted bundle zip', { zipPath, target });
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async import() {
|
|
81
|
+
this.progressCallback('Reading export metadata...');
|
|
82
|
+
const meta = this.readExportMeta();
|
|
83
|
+
this.validate(meta);
|
|
84
|
+
|
|
85
|
+
logger.info('import started', {
|
|
86
|
+
appDid: meta.appDid,
|
|
87
|
+
blockletName: meta.blockletName,
|
|
88
|
+
sourceServer: meta.sourceServerDid,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.progressCallback('Extracting bundle archives...');
|
|
92
|
+
await this.extractBundleZips();
|
|
93
|
+
|
|
94
|
+
this.progressCallback('Installing blocklet from export...');
|
|
95
|
+
const result = await installApplicationFromBackup({
|
|
96
|
+
url: `file://${this.inputDir}`,
|
|
97
|
+
appSk: meta.appSk,
|
|
98
|
+
moveDir: false,
|
|
99
|
+
manager: this.manager,
|
|
100
|
+
states: this.states,
|
|
101
|
+
sync: true,
|
|
102
|
+
context: { startImmediately: false, skipHooks: true, ...this.context },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
logger.info('import completed', { appDid: meta.appDid });
|
|
106
|
+
return { meta, blocklet: result };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { BlockletImporter };
|
package/lib/event/index.js
CHANGED
|
@@ -134,6 +134,28 @@ module.exports = ({
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// When receiving installed event from CLI (e.g. blocklet import), set up routing
|
|
138
|
+
// in the daemon. The router deduplicates queued changes, so this is safe even if
|
|
139
|
+
// handleBlockletEvent already processed routing for this blocklet.
|
|
140
|
+
if (name === BlockletEvents.installed) {
|
|
141
|
+
const blocklet = data?.blocklet || data;
|
|
142
|
+
if (blocklet?.meta?.did) {
|
|
143
|
+
ensureBlockletRouting(blocklet, data?.context || {})
|
|
144
|
+
.then((changed) => {
|
|
145
|
+
if (changed) {
|
|
146
|
+
return handleBlockletRouting({
|
|
147
|
+
did: blocklet.meta.did,
|
|
148
|
+
message: 'Install blocklet (eventHub)',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
})
|
|
153
|
+
.catch((error) => {
|
|
154
|
+
logger.error('routing setup from eventHub failed', { event: name, did: blocklet?.meta?.did, error });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
if (typeof eventHandler === 'function') {
|
|
138
160
|
eventHandler({ name, data });
|
|
139
161
|
}
|
|
@@ -467,6 +489,11 @@ module.exports = ({
|
|
|
467
489
|
did: blocklet.meta.did,
|
|
468
490
|
message: 'Connect blocklet to DID Spaces',
|
|
469
491
|
});
|
|
492
|
+
} else if (BlockletEvents.gatewayConfigChanged === eventName) {
|
|
493
|
+
await handleBlockletRouting({
|
|
494
|
+
did: blocklet.meta.did,
|
|
495
|
+
message: 'Update blocklet gateway config',
|
|
496
|
+
});
|
|
470
497
|
} else if (BlockletEvents.backupProgress === eventName && payload?.completed) {
|
|
471
498
|
try {
|
|
472
499
|
const backupEndpoint = getBackupEndpoint(blocklet?.environments);
|
|
@@ -700,6 +727,7 @@ module.exports = ({
|
|
|
700
727
|
BlockletEvents.downloadBundleProgress,
|
|
701
728
|
|
|
702
729
|
BlockletEvents.spaceConnected,
|
|
730
|
+
BlockletEvents.gatewayConfigChanged,
|
|
703
731
|
BlockletEvents.nftConsumed,
|
|
704
732
|
|
|
705
733
|
BlockletEvents.configTheme,
|
package/lib/index.js
CHANGED
|
@@ -413,6 +413,7 @@ function ABTNode(options) {
|
|
|
413
413
|
backupBlocklet: blockletManager.backupBlocklet.bind(blockletManager),
|
|
414
414
|
abortBlockletBackup: blockletManager.abortBlockletBackup.bind(blockletManager),
|
|
415
415
|
restoreBlocklet: blockletManager.restoreBlocklet.bind(blockletManager),
|
|
416
|
+
importBlocklet: blockletManager.importBlocklet.bind(blockletManager),
|
|
416
417
|
migrateApplicationToStructV2: blockletManager.migrateApplicationToStructV2.bind(blockletManager),
|
|
417
418
|
syncAppConfig: blockletManager.syncAppConfig.bind(blockletManager),
|
|
418
419
|
setBlockletBlurhash: blockletManager.setBlockletBlurhash.bind(blockletManager),
|
package/lib/router/helper.js
CHANGED
|
@@ -1897,12 +1897,13 @@ module.exports = function getRouterHelpers({
|
|
|
1897
1897
|
getAllRoutingParams: async () => {
|
|
1898
1898
|
try {
|
|
1899
1899
|
// Parallelize all independent async operations
|
|
1900
|
-
const [info, { sites }, services, certificates, wafDisabledList] = await Promise.all([
|
|
1900
|
+
const [info, { sites }, services, certificates, wafDisabledList, noIndexOverrides] = await Promise.all([
|
|
1901
1901
|
nodeState._read(),
|
|
1902
1902
|
readAllRoutingSites(),
|
|
1903
1903
|
blockletState.getServices(),
|
|
1904
1904
|
httpsEnabled ? certManager.getAllNormal() : Promise.resolve([]),
|
|
1905
1905
|
states.blockletExtras.getWafDisabledBlocklets(),
|
|
1906
|
+
states.blockletExtras.getNoIndexOverrides(),
|
|
1906
1907
|
]);
|
|
1907
1908
|
|
|
1908
1909
|
// Fetch site info for WAF disabled blocklets in parallel
|
|
@@ -1921,6 +1922,7 @@ module.exports = function getRouterHelpers({
|
|
|
1921
1922
|
services,
|
|
1922
1923
|
nodeInfo: info,
|
|
1923
1924
|
wafDisabledBlocklets,
|
|
1925
|
+
noIndexOverrides,
|
|
1924
1926
|
};
|
|
1925
1927
|
} catch (err) {
|
|
1926
1928
|
logger.error('Read routing rules failed', { error: err });
|
|
@@ -1939,10 +1941,11 @@ module.exports = function getRouterHelpers({
|
|
|
1939
1941
|
getBlockletRoutingParams: async (blockletDid) => {
|
|
1940
1942
|
try {
|
|
1941
1943
|
// Parallelize all independent async operations
|
|
1942
|
-
const [info, sites, certificates] = await Promise.all([
|
|
1944
|
+
const [info, sites, certificates, noIndexOverrides] = await Promise.all([
|
|
1943
1945
|
nodeState._read(),
|
|
1944
1946
|
readBlockletRoutingSite(blockletDid),
|
|
1945
1947
|
httpsEnabled ? certManager.getAllNormal() : Promise.resolve([]),
|
|
1948
|
+
states.blockletExtras.getNoIndexOverrides(),
|
|
1946
1949
|
]);
|
|
1947
1950
|
|
|
1948
1951
|
if (!sites || sites.length === 0) {
|
|
@@ -1958,6 +1961,7 @@ module.exports = function getRouterHelpers({
|
|
|
1958
1961
|
headers: get(info, 'routing.headers', {}),
|
|
1959
1962
|
nodeInfo: info,
|
|
1960
1963
|
wafDisabledBlocklets: [],
|
|
1964
|
+
noIndexOverrides,
|
|
1961
1965
|
};
|
|
1962
1966
|
} catch (err) {
|
|
1963
1967
|
logger.error('router: getBlockletRoutingParams failed', { blockletDid, error: err });
|
|
@@ -1997,6 +2001,7 @@ module.exports = function getRouterHelpers({
|
|
|
1997
2001
|
headers: get(info, 'routing.headers', {}),
|
|
1998
2002
|
nodeInfo: info,
|
|
1999
2003
|
wafDisabledBlocklets,
|
|
2004
|
+
noIndexOverrides: {},
|
|
2000
2005
|
};
|
|
2001
2006
|
} catch (err) {
|
|
2002
2007
|
logger.error('router: getSystemRoutingParams failed', { error: err });
|
package/lib/router/index.js
CHANGED
|
@@ -262,6 +262,7 @@ class Router {
|
|
|
262
262
|
services = [],
|
|
263
263
|
nodeInfo = {},
|
|
264
264
|
wafDisabledBlocklets = [],
|
|
265
|
+
noIndexOverrides = {},
|
|
265
266
|
} = (await this[fn]()) || {};
|
|
266
267
|
|
|
267
268
|
if (!Array.isArray(sites)) {
|
|
@@ -332,6 +333,7 @@ class Router {
|
|
|
332
333
|
wafPolicy,
|
|
333
334
|
cacheEnabled: isGatewayCacheEnabled(nodeInfo),
|
|
334
335
|
wafDisabledBlocklets,
|
|
336
|
+
noIndexOverrides,
|
|
335
337
|
enableDefaultServer: nodeInfo.routing.enableDefaultServer ?? false,
|
|
336
338
|
enableIpServer: nodeInfo.routing.enableIpServer ?? false,
|
|
337
339
|
};
|
|
@@ -434,13 +436,21 @@ class Router {
|
|
|
434
436
|
// eslint-disable-next-line no-await-in-loop
|
|
435
437
|
const rawParams = await this.getBlockletRoutingParams(did);
|
|
436
438
|
if (rawParams && rawParams.sites && rawParams.sites.length > 0) {
|
|
437
|
-
const {
|
|
439
|
+
const {
|
|
440
|
+
sites,
|
|
441
|
+
certificates,
|
|
442
|
+
headers = {},
|
|
443
|
+
nodeInfo = {},
|
|
444
|
+
wafDisabledBlocklets = [],
|
|
445
|
+
noIndexOverrides = {},
|
|
446
|
+
} = rawParams;
|
|
438
447
|
const blockletParams = {
|
|
439
448
|
routingTable: getRoutingTable({ sites, nodeInfo }),
|
|
440
449
|
certificates,
|
|
441
450
|
commonHeaders: headers,
|
|
442
451
|
nodeInfo: pick(nodeInfo, ['did', 'name', 'version', 'port', 'mode', 'enableWelcomePage', 'routing']),
|
|
443
452
|
wafDisabledBlocklets,
|
|
453
|
+
noIndexOverrides,
|
|
444
454
|
};
|
|
445
455
|
|
|
446
456
|
if (typeof this.provider.updateSingleBlocklet === 'function') {
|
|
@@ -7,6 +7,21 @@ const logger = require('@abtnode/logger')('@abtnode/core:router:security:limiter
|
|
|
7
7
|
|
|
8
8
|
const states = require('../../states');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Check if an IP is a loopback or private address that should never be blocked.
|
|
12
|
+
* Blocking these IPs can cause the server to lock itself out.
|
|
13
|
+
*/
|
|
14
|
+
function isSkippedIP(ip) {
|
|
15
|
+
return (
|
|
16
|
+
ip === '127.0.0.1' ||
|
|
17
|
+
ip === '::1' ||
|
|
18
|
+
ip === '0.0.0.0' ||
|
|
19
|
+
ip.startsWith('10.') ||
|
|
20
|
+
ip.startsWith('192.168.') ||
|
|
21
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
26
|
* Create a rate limiter for suspicious requests.
|
|
12
27
|
* https://github.com/animir/node-rate-limiter-flexible/wiki/Options
|
|
@@ -29,6 +44,11 @@ function createLimiter(options, onBlocked = () => {}) {
|
|
|
29
44
|
logger.info('Rate limiter created', { options });
|
|
30
45
|
|
|
31
46
|
const check = async (ip, points = 1) => {
|
|
47
|
+
if (isSkippedIP(ip)) {
|
|
48
|
+
logger.debug('Skipping private/loopback IP from rate limiting', { ip });
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
try {
|
|
33
53
|
const result = await limiter.consume(ip, points);
|
|
34
54
|
logger.debug('Rate limit not exceeded', { ip, result });
|
|
@@ -351,6 +351,18 @@ class BlockletExtrasState extends BaseState {
|
|
|
351
351
|
getWafDisabledBlocklets() {
|
|
352
352
|
return super.find({ where: { 'settings.gateway.wafPolicy.enabled': false } }, { did: 1 });
|
|
353
353
|
}
|
|
354
|
+
|
|
355
|
+
async getNoIndexOverrides() {
|
|
356
|
+
const docs = await super.find({}, { did: 1, configs: 1 });
|
|
357
|
+
const overrides = {};
|
|
358
|
+
for (const doc of docs) {
|
|
359
|
+
const config = (doc.configs || []).find((c) => c.key === 'APP_NO_INDEX');
|
|
360
|
+
if (config && config.value !== undefined && config.value !== '') {
|
|
361
|
+
overrides[doc.did] = !['false', '0'].includes(String(config.value).toLowerCase());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return overrides;
|
|
365
|
+
}
|
|
354
366
|
}
|
|
355
367
|
|
|
356
368
|
module.exports = BlockletExtrasState;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "1.17.
|
|
6
|
+
"version": "1.17.11-beta-20260225-043848-68611a07",
|
|
7
7
|
"description": "",
|
|
8
8
|
"main": "lib/index.js",
|
|
9
9
|
"files": [
|
|
@@ -17,19 +17,19 @@
|
|
|
17
17
|
"author": "wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)",
|
|
18
18
|
"license": "Apache-2.0",
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@abtnode/analytics": "1.17.
|
|
21
|
-
"@abtnode/auth": "1.17.
|
|
22
|
-
"@abtnode/certificate-manager": "1.17.
|
|
23
|
-
"@abtnode/constant": "1.17.
|
|
24
|
-
"@abtnode/cron": "1.17.
|
|
25
|
-
"@abtnode/db-cache": "1.17.
|
|
26
|
-
"@abtnode/docker-utils": "1.17.
|
|
27
|
-
"@abtnode/logger": "1.17.
|
|
28
|
-
"@abtnode/models": "1.17.
|
|
29
|
-
"@abtnode/queue": "1.17.
|
|
30
|
-
"@abtnode/rbac": "1.17.
|
|
31
|
-
"@abtnode/router-provider": "1.17.
|
|
32
|
-
"@abtnode/util": "1.17.
|
|
20
|
+
"@abtnode/analytics": "1.17.11-beta-20260225-043848-68611a07",
|
|
21
|
+
"@abtnode/auth": "1.17.11-beta-20260225-043848-68611a07",
|
|
22
|
+
"@abtnode/certificate-manager": "1.17.11-beta-20260225-043848-68611a07",
|
|
23
|
+
"@abtnode/constant": "1.17.11-beta-20260225-043848-68611a07",
|
|
24
|
+
"@abtnode/cron": "1.17.11-beta-20260225-043848-68611a07",
|
|
25
|
+
"@abtnode/db-cache": "1.17.11-beta-20260225-043848-68611a07",
|
|
26
|
+
"@abtnode/docker-utils": "1.17.11-beta-20260225-043848-68611a07",
|
|
27
|
+
"@abtnode/logger": "1.17.11-beta-20260225-043848-68611a07",
|
|
28
|
+
"@abtnode/models": "1.17.11-beta-20260225-043848-68611a07",
|
|
29
|
+
"@abtnode/queue": "1.17.11-beta-20260225-043848-68611a07",
|
|
30
|
+
"@abtnode/rbac": "1.17.11-beta-20260225-043848-68611a07",
|
|
31
|
+
"@abtnode/router-provider": "1.17.11-beta-20260225-043848-68611a07",
|
|
32
|
+
"@abtnode/util": "1.17.11-beta-20260225-043848-68611a07",
|
|
33
33
|
"@arcblock/did": "1.29.8",
|
|
34
34
|
"@arcblock/did-connect-js": "1.29.8",
|
|
35
35
|
"@arcblock/did-ext": "1.29.8",
|
|
@@ -40,15 +40,15 @@
|
|
|
40
40
|
"@arcblock/pm2-events": "^0.0.5",
|
|
41
41
|
"@arcblock/validator": "1.29.8",
|
|
42
42
|
"@arcblock/vc": "1.29.8",
|
|
43
|
-
"@blocklet/constant": "1.17.
|
|
43
|
+
"@blocklet/constant": "1.17.11-beta-20260225-043848-68611a07",
|
|
44
44
|
"@blocklet/did-space-js": "^1.2.16",
|
|
45
|
-
"@blocklet/env": "1.17.
|
|
45
|
+
"@blocklet/env": "1.17.11-beta-20260225-043848-68611a07",
|
|
46
46
|
"@blocklet/error": "^0.3.5",
|
|
47
|
-
"@blocklet/meta": "1.17.
|
|
48
|
-
"@blocklet/resolver": "1.17.
|
|
49
|
-
"@blocklet/sdk": "1.17.
|
|
50
|
-
"@blocklet/server-js": "1.17.
|
|
51
|
-
"@blocklet/store": "1.17.
|
|
47
|
+
"@blocklet/meta": "1.17.11-beta-20260225-043848-68611a07",
|
|
48
|
+
"@blocklet/resolver": "1.17.11-beta-20260225-043848-68611a07",
|
|
49
|
+
"@blocklet/sdk": "1.17.11-beta-20260225-043848-68611a07",
|
|
50
|
+
"@blocklet/server-js": "1.17.11-beta-20260225-043848-68611a07",
|
|
51
|
+
"@blocklet/store": "1.17.11-beta-20260225-043848-68611a07",
|
|
52
52
|
"@blocklet/theme": "^3.5.2",
|
|
53
53
|
"@fidm/x509": "^1.2.1",
|
|
54
54
|
"@ocap/mcrypto": "1.29.8",
|
|
@@ -113,5 +113,5 @@
|
|
|
113
113
|
"express": "^4.18.2",
|
|
114
114
|
"unzipper": "^0.10.11"
|
|
115
115
|
},
|
|
116
|
-
"gitHead": "
|
|
116
|
+
"gitHead": "984a5f485c38205d8240b667cca26ddad0bbf6ac"
|
|
117
117
|
}
|