@kne/fastify-app-manager 0.1.3 → 0.1.5
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/README.md +3 -3
- package/libs/services/app.js +44 -22
- package/libs/utils/zip.js +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ deploy(versionId)
|
|
|
117
117
|
|
|
118
118
|
| 特性 | 说明 |
|
|
119
119
|
|------|------|
|
|
120
|
-
| 版本上传 | Zip Slip
|
|
120
|
+
| 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
|
|
121
121
|
| 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
|
|
122
122
|
| 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
|
|
123
123
|
| 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
|
|
@@ -196,7 +196,7 @@ await fastify.listen({ port: 3000 });
|
|
|
196
196
|
| `healthCheckTimeoutMs` | number | 否 | `30000` | 探测总超时(毫秒) |
|
|
197
197
|
| `healthCheckIntervalMs` | number | 否 | `1000` | 探测轮询间隔(毫秒) |
|
|
198
198
|
| `maxZipSize` | number | 否 | `209715200` | zip 最大字节(200MB);同步 multipart 限制 |
|
|
199
|
-
| `maxZipEntries` | number | 否 | `20000` | zip
|
|
199
|
+
| `maxZipEntries` | number | 否 | `20000` | zip 最大条目数(不含被丢弃的 `node_modules` 条目) |
|
|
200
200
|
| `npmInstallTimeoutMs` | number | 否 | `600000` | `server` 目录 `npm install` 超时 |
|
|
201
201
|
| `sseReplayLines` | number | 否 | `100` | SSE 连接时回放最近行数 |
|
|
202
202
|
| `sseHeartbeatMs` | number | 否 | `15000` | SSE 心跳间隔 |
|
|
@@ -311,7 +311,7 @@ await fastify.listen({ port: 3000 });
|
|
|
311
311
|
| version | string | 是 | 版本号;同应用内唯一 |
|
|
312
312
|
| label | string | 否 | 版本说明 |
|
|
313
313
|
|
|
314
|
-
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`
|
|
314
|
+
成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration` 等)。包在临时目录完成解压、校验与 `npm install` 后才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
|
|
315
315
|
|
|
316
316
|
##### GET `{prefix}/app/version/list`
|
|
317
317
|
|
package/libs/services/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
const path = require('node:path');
|
|
3
|
+
const crypto = require('node:crypto');
|
|
3
4
|
const fs = require('fs-extra');
|
|
4
5
|
const portPool = require('../utils/portPool');
|
|
5
6
|
const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys } = require('../utils/env');
|
|
@@ -184,41 +185,62 @@ module.exports = fp(async (fastify, options) => {
|
|
|
184
185
|
if (!app) {
|
|
185
186
|
httpError(404, `app ${name} not found; create it first`);
|
|
186
187
|
}
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
// The (app_name, version) unique index also covers soft-deleted rows, so they must be purged before reuse.
|
|
189
|
+
const dup = await models.appVersion.findOne({ where: { appName: name, version }, paranoid: false });
|
|
190
|
+
if (dup && !dup.isSoftDeleted()) {
|
|
189
191
|
httpError(409, `version ${version} already exists`);
|
|
190
192
|
}
|
|
193
|
+
if (dup) {
|
|
194
|
+
await dup.destroy({ force: true });
|
|
195
|
+
}
|
|
191
196
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
artifactPath: path.join(app.rootPath, 'versions', 'pending'),
|
|
197
|
-
hasMigration: false,
|
|
198
|
-
migrationPath: options.sqlPath
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const artifactPath = path.join(app.rootPath, 'versions', String(versionRow.id));
|
|
197
|
+
// Prepare outside the version table so failed or in-progress uploads never appear in the version list.
|
|
198
|
+
const versionsDir = path.join(app.rootPath, 'versions');
|
|
199
|
+
const stagingPath = path.join(versionsDir, `.staging-${crypto.randomUUID()}`);
|
|
200
|
+
let prepared;
|
|
202
201
|
try {
|
|
203
|
-
|
|
202
|
+
prepared = await prepareVersionArtifact({
|
|
204
203
|
zipBuffer,
|
|
205
|
-
artifactPath,
|
|
204
|
+
artifactPath: stagingPath,
|
|
206
205
|
maxZipSize: options.maxZipSize,
|
|
207
206
|
maxZipEntries: options.maxZipEntries,
|
|
208
207
|
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
209
208
|
sqlPath: options.sqlPath
|
|
210
209
|
});
|
|
211
|
-
await versionRow.update({
|
|
212
|
-
artifactPath,
|
|
213
|
-
hasMigration: prepared.hasMigration,
|
|
214
|
-
migrationPath: prepared.migrationPath
|
|
215
|
-
});
|
|
216
|
-
return versionRow.toJSON();
|
|
217
210
|
} catch (e) {
|
|
218
|
-
await fs.remove(
|
|
219
|
-
await versionRow.destroy().catch(() => {});
|
|
211
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
220
212
|
httpError(400, e.message);
|
|
221
213
|
}
|
|
214
|
+
|
|
215
|
+
let artifactPath = null;
|
|
216
|
+
try {
|
|
217
|
+
return await models.appVersion.sequelize.transaction(async transaction => {
|
|
218
|
+
const versionRow = await models.appVersion.create(
|
|
219
|
+
{
|
|
220
|
+
appName: name,
|
|
221
|
+
version,
|
|
222
|
+
label: label || null,
|
|
223
|
+
artifactPath: stagingPath,
|
|
224
|
+
hasMigration: prepared.hasMigration,
|
|
225
|
+
migrationPath: prepared.migrationPath
|
|
226
|
+
},
|
|
227
|
+
{ transaction }
|
|
228
|
+
);
|
|
229
|
+
artifactPath = path.join(versionsDir, String(versionRow.id));
|
|
230
|
+
await fs.move(stagingPath, artifactPath);
|
|
231
|
+
await versionRow.update({ artifactPath }, { transaction });
|
|
232
|
+
return versionRow.toJSON();
|
|
233
|
+
});
|
|
234
|
+
} catch (e) {
|
|
235
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
236
|
+
if (artifactPath) {
|
|
237
|
+
await fs.remove(artifactPath).catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
if (e.name === 'SequelizeUniqueConstraintError') {
|
|
240
|
+
httpError(409, `version ${version} already exists`);
|
|
241
|
+
}
|
|
242
|
+
throw e;
|
|
243
|
+
}
|
|
222
244
|
};
|
|
223
245
|
|
|
224
246
|
const listVersions = async ({ name, perPage = 20, currentPage = 1 }) => {
|
package/libs/utils/zip.js
CHANGED
|
@@ -7,6 +7,10 @@ const isPathInside = (parent, child) => {
|
|
|
7
7
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
const normalizeEntryName = name => name.replace(/^[/\\]+/, '').replace(/\\/g, '/');
|
|
11
|
+
|
|
12
|
+
const isNodeModulesEntry = name => normalizeEntryName(name).split('/').includes('node_modules');
|
|
13
|
+
|
|
10
14
|
const extractZipSafe = async (zipBuffer, destDir, { maxZipSize, maxZipEntries }) => {
|
|
11
15
|
if (!Buffer.isBuffer(zipBuffer)) {
|
|
12
16
|
throw new Error('zip content must be a Buffer');
|
|
@@ -17,13 +21,14 @@ const extractZipSafe = async (zipBuffer, destDir, { maxZipSize, maxZipEntries })
|
|
|
17
21
|
|
|
18
22
|
await fs.ensureDir(destDir);
|
|
19
23
|
const zip = new AdmZip(zipBuffer);
|
|
20
|
-
|
|
24
|
+
// Dependencies are reinstalled after extraction, so bundled node_modules are dropped before the entry limit applies.
|
|
25
|
+
const entries = zip.getEntries().filter(entry => !isNodeModulesEntry(entry.entryName));
|
|
21
26
|
if (entries.length > maxZipEntries) {
|
|
22
27
|
throw new Error(`zip has too many entries (${entries.length} > ${maxZipEntries})`);
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
for (const entry of entries) {
|
|
26
|
-
const entryName = entry.entryName
|
|
31
|
+
const entryName = normalizeEntryName(entry.entryName);
|
|
27
32
|
if (!entryName || entryName.includes('\0')) {
|
|
28
33
|
throw new Error('invalid zip entry name');
|
|
29
34
|
}
|