@kne/fastify-app-manager 0.1.9 → 0.1.10
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 +15 -6
- package/index.js +6 -0
- package/libs/controllers/app.js +8 -0
- package/libs/controllers/gateway.js +5 -2
- package/libs/services/app.js +62 -5
- package/libs/services/dbops.js +2 -10
- package/libs/utils/dbIdentity.js +16 -7
- package/libs/utils/env.js +30 -11
- package/libs/utils/pathRewrite.js +28 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npm i --save @kne/fastify-app-manager
|
|
|
22
22
|
|
|
23
23
|
适用场景:内网/堡垒机上的业务应用集中托管;不负责多机编排、硬磁盘配额或 WebSocket 升级。
|
|
24
24
|
|
|
25
|
-
> **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body
|
|
25
|
+
> **关键设计**:子应用前端通过部署时注入的 `runtimePublicUrl` / `runtimeApiUrl` 感知对外前缀;网关对路径模式剥前缀反代,并改写 Location / Cookie / 文本 body 兜底(JS 脚本不改写,避免与 runtimeApiUrl 重复拼前缀)。Scheme B(子进程挂 `APP_BASE_PATH`)已弃用。
|
|
26
26
|
>
|
|
27
27
|
> **存储隔离**:同时注入 `window.__LOCAL_STORAGE_PREFIX="<应用名>"`,`@kne/local-storage`(≥0.1.2,`@kne/token-storage` 底层依赖)读到后自动给 key 加 `<应用名>:` 前缀,避免同源挂载的子应用与宿主、其它子应用互相覆盖登录 token 等本地存储。
|
|
28
28
|
|
|
@@ -106,7 +106,7 @@ deploy(versionId)
|
|
|
106
106
|
| `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
|
|
107
107
|
| 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
|
|
108
108
|
| 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
|
|
109
|
-
| 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated
|
|
109
|
+
| 应用自有库 | 应用 `env` 含完整 `DB_*` 且 `DB_DEDICATED` 不为 `false` 时为独享库(`dbScope=dedicated`);`DB_DEDICATED=false` 保留配置但回落共享库。`dbScope` 始终由 env 推导,与启动注入的连接一致;独享库若带 `DB_TABLE_PREFIX`(与其它系统共用库)只认该前缀的表 |
|
|
110
110
|
| 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_` 并由系统写回应用 env(接口不返回、不可修改);子应用 sequelize 须读取该变量建表 |
|
|
111
111
|
| 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
|
|
112
112
|
|
|
@@ -191,6 +191,9 @@ await fastify.listen({ port: 3000 });
|
|
|
191
191
|
| `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
|
|
192
192
|
| `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
|
|
193
193
|
| `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 按键名正则脱敏;与显式 `secretEnvKeys` 取并集 |
|
|
194
|
+
| `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
|
|
195
|
+
| `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
|
|
196
|
+
| `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
|
|
194
197
|
| `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
|
|
195
198
|
| `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
|
|
196
199
|
| `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
|
|
@@ -253,11 +256,12 @@ await fastify.listen({ port: 3000 });
|
|
|
253
256
|
| description | string | 否 | - | 描述 |
|
|
254
257
|
| category | object / string | 否 | - | 应用分类(应用中心分组用),写入 `options.category`;推荐传 fastify-group 分组对象,对象只保留 `{ code, name }`,字符串原样保存 |
|
|
255
258
|
| isPublic | boolean | 否 | `true` | 是否在公开应用中心展示,写入 `options.isPublic`;未设置视为公开 |
|
|
259
|
+
| entries | array | 否 | `[]` | 应用入口 `[{ label, path }]`,写入 `options.entries`;`path` 相对应用根(缺前导 `/` 自动补),缺 `label` 或 `path` 的项丢弃,同 `path` 只保留第一项 |
|
|
256
260
|
| env | object | 否 | `{}` | 应用自有环境变量 |
|
|
257
261
|
| pm2Config | object | 否 | `{}` | PM2 覆盖项 |
|
|
258
262
|
| options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
|
|
259
263
|
|
|
260
|
-
返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
264
|
+
返回脱敏后的应用对象(含 `port`、`pathUrl`、`category`、`entries`(每项附 `url` = 挂载前缀 + `path`)、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
|
|
261
265
|
|
|
262
266
|
##### POST `{prefix}/app/save`
|
|
263
267
|
|
|
@@ -269,6 +273,7 @@ await fastify.listen({ port: 3000 });
|
|
|
269
273
|
| label / domain / icon / description / env / pm2Config / options | - | 否 | 传入的字段才更新;`env` 按 patch 语义合并 |
|
|
270
274
|
| category | object / string / null | 否 | 合并写入 `options.category`(对象只保留 `{ code, name }`),不影响 `options` 其它键;传空值清除分类 |
|
|
271
275
|
| isPublic | boolean | 否 | 合并写入 `options.isPublic`,不影响 `options` 其它键 |
|
|
276
|
+
| entries | array / null | 否 | 整体替换 `options.entries`(规则同创建),不影响 `options` 其它键;传 `[]` / `null` 清空入口 |
|
|
272
277
|
|
|
273
278
|
##### POST `{prefix}/app/save-env`
|
|
274
279
|
|
|
@@ -426,7 +431,11 @@ await fastify.listen({ port: 3000 });
|
|
|
426
431
|
"description": "d",
|
|
427
432
|
"category": { "code": "tools", "name": "工具" },
|
|
428
433
|
"isPublic": true,
|
|
429
|
-
"pathUrl": "/app/demo/"
|
|
434
|
+
"pathUrl": "/app/demo/",
|
|
435
|
+
"entries": [
|
|
436
|
+
{ "label": "PC", "path": "/", "url": "/app/demo/" },
|
|
437
|
+
{ "label": "移动端", "path": "/mobile", "url": "/app/demo/mobile" }
|
|
438
|
+
]
|
|
430
439
|
}
|
|
431
440
|
],
|
|
432
441
|
"totalCount": 1
|
|
@@ -685,7 +694,7 @@ data: {"name":"demo","ts":1791456608533,...}
|
|
|
685
694
|
| description | string / null | 描述 |
|
|
686
695
|
| env | object | 自有环境变量 |
|
|
687
696
|
| pm2Config | object | PM2 覆盖 |
|
|
688
|
-
| options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic`
|
|
697
|
+
| options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表,`category` 为应用分类,`isPublic` 为是否公开(未设置视为公开),`entries` 为应用入口 `[{ label, path }]` |
|
|
689
698
|
| port | number | 分配端口 |
|
|
690
699
|
| status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
|
|
691
700
|
| currentVersionId | string / null | 当前部署版本 id |
|
|
@@ -775,7 +784,7 @@ rename 为 {stream}-{起始时间}.log ──→ pm2 reloadLogs ──→ gzip
|
|
|
775
784
|
##### 环境合并顺序
|
|
776
785
|
|
|
777
786
|
```
|
|
778
|
-
pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
|
|
787
|
+
pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制) → resolveSystemEnv 返回值(覆盖)
|
|
779
788
|
```
|
|
780
789
|
|
|
781
790
|
API 响应脱敏:`secretEnvKeyPattern` ∪ `options.secretEnvKeys`;明文密钥不回传,顶层 `secretEnvKeys` 只暴露键名。
|
package/index.js
CHANGED
|
@@ -36,6 +36,12 @@ module.exports = fp(
|
|
|
36
36
|
loadSampleIntervalMs: 5000,
|
|
37
37
|
loadHistoryMinutes: 10,
|
|
38
38
|
passthroughEnvKeys: [],
|
|
39
|
+
/** 宿主维护的系统变量:接口不返回、不可外部修改,resolveSystemEnv 返回的同名键会持久化 */
|
|
40
|
+
systemEnvKeys: [],
|
|
41
|
+
/** async ({ app, version, serverDir }) => env | null,每次启动进程前调用,返回值覆盖应用变量 */
|
|
42
|
+
resolveSystemEnv: null,
|
|
43
|
+
/** async ({ app }) => void,应用删除成功后调用,app 为删除前快照;报错只记日志 */
|
|
44
|
+
onAppRemoved: null,
|
|
39
45
|
secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
|
|
40
46
|
sqlPath: 'sql',
|
|
41
47
|
migrateBeforeStart: false,
|
package/libs/controllers/app.js
CHANGED
|
@@ -26,6 +26,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
26
26
|
description: { type: 'string' },
|
|
27
27
|
category: { type: ['object', 'string', 'null'] },
|
|
28
28
|
isPublic: { type: 'boolean' },
|
|
29
|
+
entries: {
|
|
30
|
+
type: ['array', 'null'],
|
|
31
|
+
items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
|
|
32
|
+
},
|
|
29
33
|
env: { type: 'object' },
|
|
30
34
|
pm2Config: { type: 'object' },
|
|
31
35
|
options: { type: 'object' }
|
|
@@ -53,6 +57,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
53
57
|
description: { type: 'string' },
|
|
54
58
|
category: { type: ['object', 'string', 'null'] },
|
|
55
59
|
isPublic: { type: 'boolean' },
|
|
60
|
+
entries: {
|
|
61
|
+
type: ['array', 'null'],
|
|
62
|
+
items: { type: 'object', properties: { label: { type: 'string' }, path: { type: 'string' } } }
|
|
63
|
+
},
|
|
56
64
|
env: { type: 'object' },
|
|
57
65
|
pm2Config: { type: 'object' },
|
|
58
66
|
options: { type: 'object' }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
const { Readable } = require('node:stream');
|
|
3
|
-
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody } = require('../utils/pathRewrite');
|
|
3
|
+
const { rewriteLocation, rewriteSetCookiePath, shouldRewriteBody, rewriteBody, tagEtag, restoreConditionalHeaders } = require('../utils/pathRewrite');
|
|
4
4
|
const { wantsHtml, renderUnavailablePage, getStatusText } = require('../utils/unavailablePage');
|
|
5
5
|
|
|
6
6
|
module.exports = fp(async (fastify, options) => {
|
|
@@ -41,13 +41,16 @@ module.exports = fp(async (fastify, options) => {
|
|
|
41
41
|
reply.send(error);
|
|
42
42
|
},
|
|
43
43
|
rewriteRequestHeaders: (req, headers) => {
|
|
44
|
-
const next = { ...headers };
|
|
44
|
+
const next = stripPrefix ? restoreConditionalHeaders(headers) : { ...headers };
|
|
45
45
|
delete next['content-length'];
|
|
46
46
|
return next;
|
|
47
47
|
},
|
|
48
48
|
rewriteHeaders: headers => {
|
|
49
49
|
const next = { ...headers };
|
|
50
50
|
if (stripPrefix) {
|
|
51
|
+
if (next.etag) {
|
|
52
|
+
next.etag = tagEtag(next.etag);
|
|
53
|
+
}
|
|
51
54
|
if (next.location) {
|
|
52
55
|
next.location = rewriteLocation(next.location, mountPrefix);
|
|
53
56
|
}
|
package/libs/services/app.js
CHANGED
|
@@ -45,6 +45,27 @@ module.exports = fp(async (fastify, options) => {
|
|
|
45
45
|
}
|
|
46
46
|
return category.code ? { code: category.code, name: category.name || category.code } : null;
|
|
47
47
|
};
|
|
48
|
+
// 应用入口:label + 相对应用根的 path;去空、补前导 /、按 path 去重
|
|
49
|
+
const normalizeEntries = entries => {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
return (Array.isArray(entries) ? entries : []).reduce((result, item) => {
|
|
52
|
+
const label = String(item?.label || '').trim();
|
|
53
|
+
const rawPath = String(item?.path || '').trim();
|
|
54
|
+
if (!label || !rawPath) {
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
const entryPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
58
|
+
if (!seen.has(entryPath)) {
|
|
59
|
+
seen.add(entryPath);
|
|
60
|
+
result.push({ label, path: entryPath });
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}, []);
|
|
64
|
+
};
|
|
65
|
+
const resolveEntries = appLike => {
|
|
66
|
+
const base = mountPrefix(appLike.name);
|
|
67
|
+
return normalizeEntries(appLike.options?.entries).map(entry => Object.assign({}, entry, { url: `${base}${entry.path}` }));
|
|
68
|
+
};
|
|
48
69
|
|
|
49
70
|
const toPublicApp = app => {
|
|
50
71
|
if (!app) {
|
|
@@ -59,6 +80,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
59
80
|
json.pathUrl = `${mountPrefix(json.name)}/`;
|
|
60
81
|
json.category = json.options?.category || null;
|
|
61
82
|
json.isPublic = isAppPublic(json);
|
|
83
|
+
json.entries = resolveEntries(json);
|
|
62
84
|
return json;
|
|
63
85
|
};
|
|
64
86
|
|
|
@@ -71,7 +93,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
71
93
|
};
|
|
72
94
|
|
|
73
95
|
const create = async data => {
|
|
74
|
-
const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
|
|
96
|
+
const { name, label, domain, icon, description, category, isPublic, entries, env, pm2Config, options: appOptions } = data;
|
|
75
97
|
if (!name || !NAME_RE.test(name)) {
|
|
76
98
|
httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
|
|
77
99
|
}
|
|
@@ -111,6 +133,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
111
133
|
if (isPublic !== undefined) {
|
|
112
134
|
normalizedOptions.isPublic = !!isPublic;
|
|
113
135
|
}
|
|
136
|
+
if (entries !== undefined) {
|
|
137
|
+
normalizedOptions.entries = normalizeEntries(entries);
|
|
138
|
+
}
|
|
114
139
|
|
|
115
140
|
const app = await models.app.create({
|
|
116
141
|
name,
|
|
@@ -118,7 +143,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
118
143
|
domain: domain || null,
|
|
119
144
|
icon: icon || null,
|
|
120
145
|
description: description || null,
|
|
121
|
-
env: omitSystemEnv(env),
|
|
146
|
+
env: omitSystemEnv(env, options.systemEnvKeys),
|
|
122
147
|
pm2Config: pm2Config || {},
|
|
123
148
|
options: normalizedOptions,
|
|
124
149
|
port,
|
|
@@ -130,7 +155,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
130
155
|
return toPublicApp(app);
|
|
131
156
|
};
|
|
132
157
|
|
|
133
|
-
const save = async ({ name, category, isPublic, ...data }) => {
|
|
158
|
+
const save = async ({ name, category, isPublic, entries, ...data }) => {
|
|
134
159
|
const app = await getByName(name);
|
|
135
160
|
const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
|
|
136
161
|
const patch = {};
|
|
@@ -161,6 +186,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
161
186
|
if (isPublic !== undefined) {
|
|
162
187
|
patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
|
|
163
188
|
}
|
|
189
|
+
if (entries !== undefined) {
|
|
190
|
+
patch.options = Object.assign({}, patch.options || app.options || {}, { entries: normalizeEntries(entries) });
|
|
191
|
+
}
|
|
164
192
|
await app.update(patch);
|
|
165
193
|
return toPublicApp(app);
|
|
166
194
|
};
|
|
@@ -170,7 +198,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
170
198
|
// 密钥类型不可撤销,只随变量删除而移除
|
|
171
199
|
const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
|
|
172
200
|
const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
|
|
173
|
-
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
201
|
+
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
174
202
|
await app.update({
|
|
175
203
|
env: nextEnv,
|
|
176
204
|
options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
|
|
@@ -211,7 +239,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
211
239
|
description: app.description,
|
|
212
240
|
category: app.options?.category || null,
|
|
213
241
|
isPublic: isAppPublic(app),
|
|
214
|
-
pathUrl: `${mountPrefix(app.name)}
|
|
242
|
+
pathUrl: `${mountPrefix(app.name)}/`,
|
|
243
|
+
entries: resolveEntries(app)
|
|
215
244
|
}));
|
|
216
245
|
return { pageData, totalCount: pageData.length };
|
|
217
246
|
};
|
|
@@ -480,6 +509,26 @@ module.exports = fp(async (fastify, options) => {
|
|
|
480
509
|
await app.reload();
|
|
481
510
|
}
|
|
482
511
|
|
|
512
|
+
if (typeof options.resolveSystemEnv === 'function') {
|
|
513
|
+
const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
|
|
514
|
+
if (systemEnv && typeof systemEnv === 'object') {
|
|
515
|
+
const persisted = {};
|
|
516
|
+
for (const [key, value] of Object.entries(systemEnv)) {
|
|
517
|
+
if (value == null) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
env[key] = String(value);
|
|
521
|
+
if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
|
|
522
|
+
persisted[key] = env[key];
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (Object.keys(persisted).length) {
|
|
526
|
+
await app.update({ env: Object.assign({}, app.env || {}, persisted) });
|
|
527
|
+
await app.reload();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
483
532
|
const pathBase = mountPrefix(app.name);
|
|
484
533
|
// Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
|
|
485
534
|
// Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
|
|
@@ -663,9 +712,17 @@ module.exports = fp(async (fastify, options) => {
|
|
|
663
712
|
}
|
|
664
713
|
}
|
|
665
714
|
|
|
715
|
+
const snapshot = app.toJSON();
|
|
666
716
|
await models.appVersion.destroy({ where: { appName: name } });
|
|
667
717
|
await fs.remove(app.rootPath).catch(() => {});
|
|
668
718
|
await app.destroy();
|
|
719
|
+
if (typeof options.onAppRemoved === 'function') {
|
|
720
|
+
try {
|
|
721
|
+
await options.onAppRemoved({ app: snapshot });
|
|
722
|
+
} catch (e) {
|
|
723
|
+
fastify.log.warn({ err: e, appName: name }, 'onAppRemoved hook failed');
|
|
724
|
+
}
|
|
725
|
+
}
|
|
669
726
|
return {
|
|
670
727
|
removed: true,
|
|
671
728
|
export: exportResult,
|
package/libs/services/dbops.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs-extra');
|
|
|
4
4
|
const AdmZip = require('adm-zip');
|
|
5
5
|
const { createSequelizeFromEnv, SQL_MIGRATIONS_TABLE } = require('../utils/migrate');
|
|
6
6
|
const { resolveAppDbEnv, hasAppDbConfig } = require('../utils/env');
|
|
7
|
-
const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix } = require('../utils/dbIdentity');
|
|
7
|
+
const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix, filterOwnedTables } = require('../utils/dbIdentity');
|
|
8
8
|
const { assertReadOnlyQuerySql } = require('../utils/sqlQueryGuard');
|
|
9
9
|
|
|
10
10
|
module.exports = fp(async (fastify, options) => {
|
|
@@ -83,15 +83,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
83
83
|
return { table, columns, primaryKey };
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
const getOwnedTables = (app, allTables) => {
|
|
87
|
-
const scope = resolveDbScope(app.env || {}, app.options || {});
|
|
88
|
-
const existing = new Set(allTables);
|
|
89
|
-
if (scope === 'dedicated') {
|
|
90
|
-
return allTables.filter(t => !isSystemTable(t));
|
|
91
|
-
}
|
|
92
|
-
const owned = normalizeOwnedTables(app.options?.ownedTables);
|
|
93
|
-
return owned.filter(t => existing.has(t));
|
|
94
|
-
};
|
|
86
|
+
const getOwnedTables = (app, allTables) => filterOwnedTables({ appEnv: app.env || {}, ownedTables: app.options?.ownedTables, allTables });
|
|
95
87
|
|
|
96
88
|
const assertTableOwned = (app, allTables, table) => {
|
|
97
89
|
const owned = getOwnedTables(app, allTables);
|
package/libs/utils/dbIdentity.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
|
-
const {
|
|
2
|
+
const { isAppDbEnabled } = require('./env');
|
|
3
3
|
|
|
4
4
|
const SYSTEM_TABLES = new Set(['_fs_sql_migrations']);
|
|
5
5
|
|
|
@@ -69,12 +69,8 @@ const assertDefaultAppDbSeparated = (defaultAppDb, hostSequelize) => {
|
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return options.dbScope;
|
|
75
|
-
}
|
|
76
|
-
return hasAppDbConfig(appEnv) ? 'dedicated' : 'shared';
|
|
77
|
-
};
|
|
72
|
+
// 与 startProcess 注入的连接一致:只看 env(独立库配置 + DB_DEDICATED 开关)。options.dbScope 只是认领表时写入的缓存,env 变化后会过期
|
|
73
|
+
const resolveDbScope = (appEnv = {}) => (isAppDbEnabled(appEnv) ? 'dedicated' : 'shared');
|
|
78
74
|
|
|
79
75
|
const normalizeOwnedTables = tables => [...new Set((Array.isArray(tables) ? tables : []).map(t => String(t || '').trim()).filter(Boolean))];
|
|
80
76
|
|
|
@@ -104,8 +100,21 @@ const resolveTablePrefix = (appName, appEnv = {}) => {
|
|
|
104
100
|
return buildTablePrefix(appName);
|
|
105
101
|
};
|
|
106
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 共享库:只认登记过的表;独立库:库内业务表全归本应用,但显式配了 DB_TABLE_PREFIX 说明该库与其它系统共用,只认本前缀的表
|
|
105
|
+
*/
|
|
106
|
+
const filterOwnedTables = ({ appEnv = {}, ownedTables, allTables = [] }) => {
|
|
107
|
+
if (resolveDbScope(appEnv) === 'dedicated') {
|
|
108
|
+
const prefix = String(appEnv.DB_TABLE_PREFIX || '').trim();
|
|
109
|
+
return allTables.filter(t => !isSystemTable(t) && (!prefix || String(t).startsWith(prefix)));
|
|
110
|
+
}
|
|
111
|
+
const existing = new Set(allTables);
|
|
112
|
+
return normalizeOwnedTables(ownedTables).filter(t => existing.has(t));
|
|
113
|
+
};
|
|
114
|
+
|
|
107
115
|
module.exports = {
|
|
108
116
|
SYSTEM_TABLES,
|
|
117
|
+
filterOwnedTables,
|
|
109
118
|
fingerprintDbConfig,
|
|
110
119
|
fingerprintFromEnv,
|
|
111
120
|
assertDefaultAppDbSeparated,
|
package/libs/utils/env.js
CHANGED
|
@@ -23,6 +23,22 @@ const hasAppDbConfig = (appEnv = {}) => {
|
|
|
23
23
|
return !!appEnv.DB_DATABASE;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
const DB_DEDICATED_KEY = 'DB_DEDICATED';
|
|
27
|
+
const DB_CONNECTION_KEYS = ['DB_DIALECT', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', 'DB_STORAGE'];
|
|
28
|
+
|
|
29
|
+
// 配好独立库后默认启用;DB_DEDICATED=false 时保留配置但回落共享库
|
|
30
|
+
const isAppDbEnabled = (appEnv = {}) =>
|
|
31
|
+
hasAppDbConfig(appEnv) &&
|
|
32
|
+
String(appEnv[DB_DEDICATED_KEY] ?? '')
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase() !== 'false';
|
|
35
|
+
|
|
36
|
+
const omitDbConnection = (appEnv = {}) => {
|
|
37
|
+
const next = Object.assign({}, appEnv);
|
|
38
|
+
DB_CONNECTION_KEYS.forEach(key => delete next[key]);
|
|
39
|
+
return next;
|
|
40
|
+
};
|
|
41
|
+
|
|
26
42
|
const buildDefaultAppDbEnv = (defaultAppDb = {}) => {
|
|
27
43
|
const dialect = defaultAppDb.dialect || 'sqlite';
|
|
28
44
|
const env = { DB_DIALECT: dialect };
|
|
@@ -59,10 +75,11 @@ const buildDefaultAppDbEnv = (defaultAppDb = {}) => {
|
|
|
59
75
|
*/
|
|
60
76
|
const resolveAppDbEnv = ({ passthroughEnvKeys = [], appEnv = {}, defaultAppDb = null, port = null, includePort = false, appName = null, injectTablePrefix = true } = {}) => {
|
|
61
77
|
const base = Object.assign({}, pickHostEnv(passthroughEnvKeys));
|
|
62
|
-
|
|
78
|
+
const useAppDb = isAppDbEnabled(appEnv || {});
|
|
79
|
+
if (defaultAppDb && !useAppDb) {
|
|
63
80
|
Object.assign(base, buildDefaultAppDbEnv(defaultAppDb));
|
|
64
81
|
}
|
|
65
|
-
Object.assign(base, appEnv || {});
|
|
82
|
+
Object.assign(base, useAppDb ? appEnv || {} : omitDbConnection(appEnv || {}));
|
|
66
83
|
if (includePort && port != null) {
|
|
67
84
|
base.PORT = String(port);
|
|
68
85
|
}
|
|
@@ -105,17 +122,17 @@ const isSecretKey = (key, pattern, secretKeys = []) => {
|
|
|
105
122
|
return isPatternSecretKey(key, pattern);
|
|
106
123
|
};
|
|
107
124
|
|
|
108
|
-
// 由系统写入维护:不在接口中返回,也不接受外部新增 / 修改 /
|
|
125
|
+
// 由系统写入维护:不在接口中返回,也不接受外部新增 / 修改 / 删除;宿主可通过 systemEnvKeys 选项追加
|
|
109
126
|
const SYSTEM_ENV_KEYS = ['DB_TABLE_PREFIX'];
|
|
110
127
|
|
|
111
|
-
const isSystemEnvKey = key => SYSTEM_ENV_KEYS.includes(key);
|
|
128
|
+
const isSystemEnvKey = (key, systemEnvKeys = []) => SYSTEM_ENV_KEYS.includes(key) || (systemEnvKeys || []).includes(key);
|
|
112
129
|
|
|
113
|
-
const omitSystemEnv = (appEnv = {}) => Object.fromEntries(Object.entries(appEnv || {}).filter(([key]) => !isSystemEnvKey(key)));
|
|
130
|
+
const omitSystemEnv = (appEnv = {}, systemEnvKeys = []) => Object.fromEntries(Object.entries(appEnv || {}).filter(([key]) => !isSystemEnvKey(key, systemEnvKeys)));
|
|
114
131
|
|
|
115
|
-
const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
|
|
132
|
+
const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [], systemEnvKeys = [] } = {}) => {
|
|
116
133
|
const masked = {};
|
|
117
134
|
const explicit = normalizeSecretKeys(secretEnvKeys);
|
|
118
|
-
for (const [key, value] of Object.entries(omitSystemEnv(appEnv))) {
|
|
135
|
+
for (const [key, value] of Object.entries(omitSystemEnv(appEnv, systemEnvKeys))) {
|
|
119
136
|
if (isSecretKey(key, secretEnvKeyPattern, explicit)) {
|
|
120
137
|
masked[key] = value == null || value === '' ? null : SECRET_MASK;
|
|
121
138
|
} else {
|
|
@@ -131,11 +148,11 @@ const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys =
|
|
|
131
148
|
* SECRET_MASK => keep existing (when key is secret by pattern or explicit list)
|
|
132
149
|
* other => set
|
|
133
150
|
*/
|
|
134
|
-
const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
|
|
151
|
+
const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEnvKeys = [], systemEnvKeys = [] } = {}) => {
|
|
135
152
|
const next = Object.assign({}, current);
|
|
136
153
|
const explicit = normalizeSecretKeys(secretEnvKeys);
|
|
137
154
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
138
|
-
if (isSystemEnvKey(key)) {
|
|
155
|
+
if (isSystemEnvKey(key, systemEnvKeys)) {
|
|
139
156
|
continue;
|
|
140
157
|
}
|
|
141
158
|
if (value === null) {
|
|
@@ -153,10 +170,10 @@ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEn
|
|
|
153
170
|
/**
|
|
154
171
|
* 响应中的密钥键列表 = 显式列表 ∪ 当前 env 中匹配 pattern 的键
|
|
155
172
|
*/
|
|
156
|
-
const collectSecretKeys = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
|
|
173
|
+
const collectSecretKeys = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [], systemEnvKeys = [] } = {}) => {
|
|
157
174
|
const explicit = normalizeSecretKeys(secretEnvKeys);
|
|
158
175
|
const fromPattern = Object.keys(appEnv || {}).filter(key => isPatternSecretKey(key, secretEnvKeyPattern));
|
|
159
|
-
return normalizeSecretKeys([...explicit, ...fromPattern]).filter(key => !isSystemEnvKey(key));
|
|
176
|
+
return normalizeSecretKeys([...explicit, ...fromPattern]).filter(key => !isSystemEnvKey(key, systemEnvKeys));
|
|
160
177
|
};
|
|
161
178
|
|
|
162
179
|
const PM2_CONFIG_KEYS = ['exec_mode', 'instances', 'autorestart', 'max_memory_restart', 'max_restarts', 'min_uptime', 'kill_timeout', 'merge_logs'];
|
|
@@ -176,6 +193,8 @@ module.exports = {
|
|
|
176
193
|
normalizeSecretKeys,
|
|
177
194
|
pickHostEnv,
|
|
178
195
|
hasAppDbConfig,
|
|
196
|
+
DB_DEDICATED_KEY,
|
|
197
|
+
isAppDbEnabled,
|
|
179
198
|
buildDefaultAppDbEnv,
|
|
180
199
|
resolveAppDbEnv,
|
|
181
200
|
mergeEnv,
|
|
@@ -27,9 +27,11 @@ const rewriteSetCookiePath = (setCookie, mountPrefix) => {
|
|
|
27
27
|
});
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
const TEXTUAL_TYPES = /text\/|
|
|
30
|
+
const TEXTUAL_TYPES = /text\/|json|xml|svg/i;
|
|
31
|
+
// 前端脚本已按注入的 runtimePublicUrl / runtimeApiUrl 自行拼前缀(如 axios baseURL + '/api/...'),再改写会得到 /app/x/app/x/api
|
|
32
|
+
const SCRIPT_TYPES = /javascript|ecmascript/i;
|
|
31
33
|
|
|
32
|
-
const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '');
|
|
34
|
+
const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '') && !SCRIPT_TYPES.test(contentType || '');
|
|
33
35
|
|
|
34
36
|
const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account']) => {
|
|
35
37
|
if (!body || !mountPrefix) {
|
|
@@ -47,9 +49,32 @@ const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account
|
|
|
47
49
|
return text;
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
// 路径模式响应体经网关改写,ETag 带上改写规则版本,与子应用原始文件及旧规则下的缓存区分;改写规则变化时递增
|
|
53
|
+
const ETAG_SUFFIX = '-gw2';
|
|
54
|
+
|
|
55
|
+
const tagEtag = etag => (typeof etag === 'string' && etag.endsWith('"') ? `${etag.slice(0, -1)}${ETAG_SUFFIX}"` : etag);
|
|
56
|
+
|
|
57
|
+
// 只有本网关签发的 ETag 才还原后交给子应用协商缓存;其它(旧规则缓存)去掉条件头,强制返回完整内容
|
|
58
|
+
const restoreConditionalHeaders = headers => {
|
|
59
|
+
const next = { ...headers };
|
|
60
|
+
const tags = String(next['if-none-match'] || '')
|
|
61
|
+
.split(',')
|
|
62
|
+
.map(tag => tag.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
if (tags.length && tags.every(tag => tag.endsWith(`${ETAG_SUFFIX}"`))) {
|
|
65
|
+
next['if-none-match'] = tags.map(tag => `${tag.slice(0, -(ETAG_SUFFIX.length + 1))}"`).join(', ');
|
|
66
|
+
} else {
|
|
67
|
+
delete next['if-none-match'];
|
|
68
|
+
delete next['if-modified-since'];
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
};
|
|
72
|
+
|
|
50
73
|
module.exports = {
|
|
51
74
|
rewriteLocation,
|
|
52
75
|
rewriteSetCookiePath,
|
|
53
76
|
shouldRewriteBody,
|
|
54
|
-
rewriteBody
|
|
77
|
+
rewriteBody,
|
|
78
|
+
tagEtag,
|
|
79
|
+
restoreConditionalHeaders
|
|
55
80
|
};
|