@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 CHANGED
@@ -117,7 +117,7 @@ deploy(versionId)
117
117
 
118
118
  | 特性 | 说明 |
119
119
  |------|------|
120
- | 版本上传 | Zip Slip 防护、条目数/体积上限、Fullstack 包格式校验、server 生产依赖安装 |
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` 等)。包非法或 npm 失败时回滚产物并返回 400。
314
+ 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration` 等)。包在临时目录完成解压、校验与 `npm install` 后才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
315
315
 
316
316
  ##### GET `{prefix}/app/version/list`
317
317
 
@@ -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
- const dup = await models.appVersion.findOne({ where: { appName: name, version } });
188
- if (dup) {
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
- const versionRow = await models.appVersion.create({
193
- appName: name,
194
- version,
195
- label: label || null,
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
- const prepared = await prepareVersionArtifact({
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(artifactPath).catch(() => {});
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
- const entries = zip.getEntries();
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.replace(/^[/\\]+/, '').replace(/\\/g, '/');
31
+ const entryName = normalizeEntryName(entry.entryName);
27
32
  if (!entryName || entryName.includes('\0')) {
28
33
  throw new Error('invalid zip entry name');
29
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {