@kne/fastify-app-manager 0.1.1 → 0.1.2

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,118 @@
1
+ const path = require('node:path');
2
+ const { hasAppDbConfig } = require('./env');
3
+
4
+ const SYSTEM_TABLES = new Set(['_fs_sql_migrations']);
5
+
6
+ const fingerprintDbConfig = db => {
7
+ if (!db || typeof db !== 'object') {
8
+ return null;
9
+ }
10
+ const dialect = db.dialect || 'sqlite';
11
+ if (dialect === 'sqlite') {
12
+ const storage = db.storage || db.host || null;
13
+ if (!storage) {
14
+ return null;
15
+ }
16
+ return `sqlite:${path.resolve(String(storage))}`;
17
+ }
18
+ const host = db.host || '127.0.0.1';
19
+ const port = db.port != null ? String(db.port) : '';
20
+ const database = db.database || db.dbName || '';
21
+ if (!database) {
22
+ return null;
23
+ }
24
+ return `${dialect}:${host}:${port}:${database}`;
25
+ };
26
+
27
+ const fingerprintFromEnv = (env = {}) => {
28
+ const dialect = env.DB_DIALECT || 'sqlite';
29
+ if (dialect === 'sqlite') {
30
+ const storage = env.DB_STORAGE || env.DB_HOST;
31
+ if (!storage) {
32
+ return null;
33
+ }
34
+ return `sqlite:${path.resolve(String(storage))}`;
35
+ }
36
+ if (!env.DB_DATABASE) {
37
+ return null;
38
+ }
39
+ return `${dialect}:${env.DB_HOST || '127.0.0.1'}:${env.DB_PORT != null ? String(env.DB_PORT) : ''}:${env.DB_DATABASE}`;
40
+ };
41
+
42
+ const assertDefaultAppDbSeparated = (defaultAppDb, hostSequelize) => {
43
+ const defaultFp = fingerprintDbConfig(defaultAppDb);
44
+ if (!defaultFp || !hostSequelize) {
45
+ return;
46
+ }
47
+ let hostCfg = null;
48
+ try {
49
+ hostCfg = hostSequelize.config || hostSequelize.options || null;
50
+ if (hostSequelize.options?.dialect === 'sqlite' || hostCfg?.dialect === 'sqlite') {
51
+ hostCfg = {
52
+ dialect: 'sqlite',
53
+ storage: hostSequelize.options?.storage || hostCfg?.storage
54
+ };
55
+ } else if (hostCfg) {
56
+ hostCfg = {
57
+ dialect: hostCfg.dialect || hostSequelize.options?.dialect,
58
+ host: hostCfg.host,
59
+ port: hostCfg.port,
60
+ database: hostCfg.database
61
+ };
62
+ }
63
+ } catch (e) {
64
+ return;
65
+ }
66
+ const hostFp = fingerprintDbConfig(hostCfg);
67
+ if (hostFp && hostFp === defaultFp) {
68
+ throw new Error('defaultAppDb must be separated from the host sequelize database (same sqlite storage or host+database)');
69
+ }
70
+ };
71
+
72
+ const resolveDbScope = (appEnv = {}, options = {}) => {
73
+ if (options.dbScope === 'dedicated' || options.dbScope === 'shared') {
74
+ return options.dbScope;
75
+ }
76
+ return hasAppDbConfig(appEnv) ? 'dedicated' : 'shared';
77
+ };
78
+
79
+ const normalizeOwnedTables = tables => [...new Set((Array.isArray(tables) ? tables : []).map(t => String(t || '').trim()).filter(Boolean))];
80
+
81
+ const isSystemTable = name => SYSTEM_TABLES.has(String(name || ''));
82
+
83
+ /**
84
+ * Sanitize app slug for sequelize table prefix: `ai-talent-saas` → `ai_talent_saas`
85
+ */
86
+ const sanitizeAppNameForTablePrefix = name =>
87
+ String(name || '')
88
+ .trim()
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, '_')
91
+ .replace(/^_+|_+$/g, '')
92
+ .replace(/_+/g, '_') || 'app';
93
+
94
+ /**
95
+ * Shared-DB table prefix: `t_{app}_` (e.g. `t_ai_talent_saas_`)
96
+ */
97
+ const buildTablePrefix = appName => `t_${sanitizeAppNameForTablePrefix(appName)}_`;
98
+
99
+ const resolveTablePrefix = (appName, appEnv = {}) => {
100
+ const explicit = appEnv && appEnv.DB_TABLE_PREFIX != null ? String(appEnv.DB_TABLE_PREFIX).trim() : '';
101
+ if (explicit) {
102
+ return explicit;
103
+ }
104
+ return buildTablePrefix(appName);
105
+ };
106
+
107
+ module.exports = {
108
+ SYSTEM_TABLES,
109
+ fingerprintDbConfig,
110
+ fingerprintFromEnv,
111
+ assertDefaultAppDbSeparated,
112
+ resolveDbScope,
113
+ normalizeOwnedTables,
114
+ isSystemTable,
115
+ sanitizeAppNameForTablePrefix,
116
+ buildTablePrefix,
117
+ resolveTablePrefix
118
+ };
package/libs/utils/env.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const SECRET_MASK = '********';
2
2
 
3
+ const normalizeSecretKeys = keys => [...new Set((Array.isArray(keys) ? keys : []).map(key => String(key || '').trim()).filter(Boolean))];
4
+
3
5
  const pickHostEnv = (passthroughEnvKeys = []) => {
4
6
  const result = {};
5
7
  for (const key of passthroughEnvKeys) {
@@ -10,22 +12,104 @@ const pickHostEnv = (passthroughEnvKeys = []) => {
10
12
  return result;
11
13
  };
12
14
 
13
- const mergeEnv = ({ passthroughEnvKeys = [], appEnv = {}, port }) => {
14
- const merged = Object.assign({}, pickHostEnv(passthroughEnvKeys), appEnv || {}, {
15
- PORT: String(port)
15
+ const hasAppDbConfig = (appEnv = {}) => {
16
+ const dialect = appEnv.DB_DIALECT;
17
+ if (!dialect) {
18
+ return false;
19
+ }
20
+ if (dialect === 'sqlite') {
21
+ return !!(appEnv.DB_STORAGE || appEnv.DB_HOST);
22
+ }
23
+ return !!appEnv.DB_DATABASE;
24
+ };
25
+
26
+ const buildDefaultAppDbEnv = (defaultAppDb = {}) => {
27
+ const dialect = defaultAppDb.dialect || 'sqlite';
28
+ const env = { DB_DIALECT: dialect };
29
+ if (dialect === 'sqlite') {
30
+ const storage = defaultAppDb.storage || defaultAppDb.host || null;
31
+ if (storage) {
32
+ env.DB_STORAGE = String(storage);
33
+ env.DB_HOST = String(storage);
34
+ }
35
+ return env;
36
+ }
37
+ if (defaultAppDb.host != null) {
38
+ env.DB_HOST = String(defaultAppDb.host);
39
+ }
40
+ if (defaultAppDb.port != null) {
41
+ env.DB_PORT = String(defaultAppDb.port);
42
+ }
43
+ if (defaultAppDb.database != null) {
44
+ env.DB_DATABASE = String(defaultAppDb.database);
45
+ }
46
+ if (defaultAppDb.username != null) {
47
+ env.DB_USERNAME = String(defaultAppDb.username);
48
+ }
49
+ if (defaultAppDb.password != null) {
50
+ env.DB_PASSWORD = String(defaultAppDb.password);
51
+ }
52
+ return env;
53
+ };
54
+
55
+ /**
56
+ * Resolve env for an app DB connection (optional PORT).
57
+ * Order: host passthrough → defaultAppDb (if app has no DB_*) → appEnv → PORT
58
+ * → shared-scope DB_TABLE_PREFIX (from appName unless already set).
59
+ */
60
+ const resolveAppDbEnv = ({ passthroughEnvKeys = [], appEnv = {}, defaultAppDb = null, port = null, includePort = false, appName = null, injectTablePrefix = true } = {}) => {
61
+ const base = Object.assign({}, pickHostEnv(passthroughEnvKeys));
62
+ if (defaultAppDb && !hasAppDbConfig(appEnv)) {
63
+ Object.assign(base, buildDefaultAppDbEnv(defaultAppDb));
64
+ }
65
+ Object.assign(base, appEnv || {});
66
+ if (includePort && port != null) {
67
+ base.PORT = String(port);
68
+ }
69
+ if (injectTablePrefix && appName) {
70
+ // lazy require avoids env ↔ dbIdentity cycle
71
+ const { resolveDbScope, resolveTablePrefix } = require('./dbIdentity');
72
+ if (resolveDbScope(appEnv || {}, {}) === 'shared' && (base.DB_TABLE_PREFIX == null || String(base.DB_TABLE_PREFIX).trim() === '')) {
73
+ base.DB_TABLE_PREFIX = resolveTablePrefix(appName, appEnv || {});
74
+ }
75
+ }
76
+ return base;
77
+ };
78
+
79
+ const mergeEnv = ({ passthroughEnvKeys = [], appEnv = {}, port, defaultAppDb = null, appName = null, injectTablePrefix = true } = {}) => {
80
+ return resolveAppDbEnv({
81
+ passthroughEnvKeys,
82
+ appEnv,
83
+ defaultAppDb,
84
+ port,
85
+ includePort: true,
86
+ appName,
87
+ injectTablePrefix
16
88
  });
17
- return merged;
18
89
  };
19
90
 
20
- const isSecretKey = (key, pattern) => {
91
+ const isPatternSecretKey = (key, pattern) => {
21
92
  const re = pattern instanceof RegExp ? pattern : new RegExp(pattern || '(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)', 'i');
22
93
  return re.test(key);
23
94
  };
24
95
 
25
- const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern } = {}) => {
96
+ /**
97
+ * @param {string} key
98
+ * @param {RegExp|string} [pattern]
99
+ * @param {string[]} [secretKeys] 显式密钥键名(存于 app.options.secretEnvKeys)
100
+ */
101
+ const isSecretKey = (key, pattern, secretKeys = []) => {
102
+ if ((secretKeys || []).includes(key)) {
103
+ return true;
104
+ }
105
+ return isPatternSecretKey(key, pattern);
106
+ };
107
+
108
+ const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
26
109
  const masked = {};
110
+ const explicit = normalizeSecretKeys(secretEnvKeys);
27
111
  for (const [key, value] of Object.entries(appEnv || {})) {
28
- if (isSecretKey(key, secretEnvKeyPattern)) {
112
+ if (isSecretKey(key, secretEnvKeyPattern, explicit)) {
29
113
  masked[key] = value == null || value === '' ? null : SECRET_MASK;
30
114
  } else {
31
115
  masked[key] = value;
@@ -37,17 +121,18 @@ const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern } = {}) => {
37
121
  /**
38
122
  * merge patch into current env.
39
123
  * null => delete key
40
- * SECRET_MASK => keep existing
124
+ * SECRET_MASK => keep existing (when key is secret by pattern or explicit list)
41
125
  * other => set
42
126
  */
43
- const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern } = {}) => {
127
+ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
44
128
  const next = Object.assign({}, current);
129
+ const explicit = normalizeSecretKeys(secretEnvKeys);
45
130
  for (const [key, value] of Object.entries(patch || {})) {
46
131
  if (value === null) {
47
132
  delete next[key];
48
133
  continue;
49
134
  }
50
- if (value === SECRET_MASK && isSecretKey(key, secretEnvKeyPattern)) {
135
+ if (value === SECRET_MASK && isSecretKey(key, secretEnvKeyPattern, explicit)) {
51
136
  continue;
52
137
  }
53
138
  next[key] = value;
@@ -55,6 +140,15 @@ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern } = {}) =
55
140
  return next;
56
141
  };
57
142
 
143
+ /**
144
+ * 响应中的密钥键列表 = 显式列表 ∪ 当前 env 中匹配 pattern 的键
145
+ */
146
+ const collectSecretKeys = (appEnv = {}, { secretEnvKeyPattern, secretEnvKeys = [] } = {}) => {
147
+ const explicit = normalizeSecretKeys(secretEnvKeys);
148
+ const fromPattern = Object.keys(appEnv || {}).filter(key => isPatternSecretKey(key, secretEnvKeyPattern));
149
+ return normalizeSecretKeys([...explicit, ...fromPattern]);
150
+ };
151
+
58
152
  const PM2_CONFIG_KEYS = ['exec_mode', 'instances', 'autorestart', 'max_memory_restart', 'max_restarts', 'min_uptime', 'kill_timeout', 'merge_logs'];
59
153
 
60
154
  const mergePm2Config = (defaults = {}, appConfig = {}) => {
@@ -69,11 +163,17 @@ const mergePm2Config = (defaults = {}, appConfig = {}) => {
69
163
 
70
164
  module.exports = {
71
165
  SECRET_MASK,
166
+ normalizeSecretKeys,
72
167
  pickHostEnv,
168
+ hasAppDbConfig,
169
+ buildDefaultAppDbEnv,
170
+ resolveAppDbEnv,
73
171
  mergeEnv,
172
+ isPatternSecretKey,
74
173
  isSecretKey,
75
174
  maskEnvForResponse,
76
175
  applyEnvPatch,
176
+ collectSecretKeys,
77
177
  PM2_CONFIG_KEYS,
78
178
  mergePm2Config
79
179
  };
@@ -22,20 +22,34 @@ const appendLog = async (filePath, content) => {
22
22
  await fs.appendFile(filePath, content.endsWith('\n') ? content : `${content}\n`);
23
23
  };
24
24
 
25
- const readLogTail = async (filePath, { perPage = 100, currentPage = 1 } = {}) => {
25
+ const readLogTail = async (filePath, { perPage = 100, currentPage = 1, beforeLine } = {}) => {
26
+ const size = Math.min(100, Math.max(1, Number(perPage) || 100));
27
+ const page = Math.max(1, Number(currentPage) || 1);
26
28
  if (!(await fs.pathExists(filePath))) {
27
- return { pageData: [], totalCount: 0 };
29
+ return { pageData: [], totalCount: 0, hasMore: false };
28
30
  }
29
31
  const text = await fs.readFile(filePath, 'utf8');
30
32
  const lines = text.split(/\r?\n/).filter((line, idx, arr) => !(idx === arr.length - 1 && line === ''));
31
33
  const totalCount = lines.length;
32
- const start = Math.max(0, totalCount - perPage * currentPage);
33
- const end = Math.max(0, totalCount - perPage * (currentPage - 1));
34
+ let start;
35
+ let end;
36
+ if (beforeLine != null && beforeLine !== '' && Number(beforeLine) > 0) {
37
+ // 取 line < beforeLine 的尾部 size 行(0-based slice end = beforeLine-1)
38
+ end = Math.min(Math.max(0, Number(beforeLine) - 1), totalCount);
39
+ start = Math.max(0, end - size);
40
+ } else {
41
+ start = Math.max(0, totalCount - size * page);
42
+ end = Math.max(0, totalCount - size * (page - 1));
43
+ }
34
44
  const pageData = lines.slice(start, end).map((content, i) => ({
35
45
  line: start + i + 1,
36
46
  content
37
47
  }));
38
- return { pageData: pageData.reverse(), totalCount };
48
+ return {
49
+ pageData: pageData.reverse(),
50
+ totalCount,
51
+ hasMore: start > 0
52
+ };
39
53
  };
40
54
 
41
55
  const readLastLines = async (filePath, n = 100) => {
@@ -1,6 +1,5 @@
1
1
  const path = require('node:path');
2
2
  const fs = require('fs-extra');
3
- const { Sequelize } = require('sequelize');
4
3
  const { listSqlFiles } = require('./validatePackage');
5
4
 
6
5
  const SQL_MIGRATIONS_TABLE = '_fs_sql_migrations';
@@ -8,20 +7,21 @@ const SQL_MIGRATIONS_TABLE = '_fs_sql_migrations';
8
7
  /**
9
8
  * Run .sql files under server/{sqlPath} with the same tracking semantics as @kne/fastify-sequelize.
10
9
  */
11
- const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {} }) => {
10
+ const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {}, Sequelize: SequelizeCtor } = {}) => {
12
11
  const sqlDir = path.join(serverDir, sqlPath);
13
12
  const files = await listSqlFiles(sqlDir);
14
13
  if (!files.length) {
15
14
  return { executed: [], skipped: true };
16
15
  }
17
16
 
18
- const sequelize = createSequelizeFromEnv(env);
17
+ const sequelize = createSequelizeFromEnv(env, SequelizeCtor);
19
18
  try {
20
19
  await sequelize.authenticate();
20
+ // TIMESTAMP is portable across sqlite / postgres / mysql (avoid DATETIME — missing on PG)
21
21
  await sequelize.query(
22
22
  `CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
23
23
  name VARCHAR(255) PRIMARY KEY,
24
- executed_at DATETIME
24
+ executed_at TIMESTAMP
25
25
  )`
26
26
  );
27
27
 
@@ -46,7 +46,12 @@ const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {} }) => {
46
46
  }
47
47
  };
48
48
 
49
- const createSequelizeFromEnv = env => {
49
+ const createSequelizeFromEnv = (env, SequelizeCtor) => {
50
+ let Sequelize = SequelizeCtor;
51
+ if (typeof Sequelize !== 'function') {
52
+ const mod = require('sequelize');
53
+ Sequelize = typeof mod === 'function' ? mod : mod.Sequelize;
54
+ }
50
55
  const dialect = env.DB_DIALECT || 'sqlite';
51
56
  if (dialect === 'sqlite') {
52
57
  return new Sequelize({
@@ -0,0 +1,72 @@
1
+ const WRITE_OR_DDL = /\b(INSERT|UPDATE|DELETE|REPLACE|MERGE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|ATTACH|DETACH|VACUUM|REINDEX|INTO)\b/i;
2
+
3
+ const ALLOWED_START = /^(WITH|SELECT|EXPLAIN|SHOW|DESCRIBE|DESC|PRAGMA)\b/i;
4
+
5
+ const stripSqlComments = sql =>
6
+ String(sql || '')
7
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
8
+ .replace(/--[^\n]*/g, ' ')
9
+ .replace(/\s+/g, ' ')
10
+ .trim();
11
+
12
+ /**
13
+ * Validate read-only query SQL. Throws Error with message on reject.
14
+ * @returns {{ sql: string, tables: string[] }}
15
+ */
16
+ const assertReadOnlyQuerySql = rawSql => {
17
+ const sql = stripSqlComments(rawSql);
18
+ if (!sql) {
19
+ throw new Error('sql is required');
20
+ }
21
+ if (sql.includes(';')) {
22
+ throw new Error('multiple statements are not allowed');
23
+ }
24
+ if (!ALLOWED_START.test(sql)) {
25
+ throw new Error('only read-only queries are allowed (SELECT / WITH / EXPLAIN / SHOW / DESCRIBE / PRAGMA)');
26
+ }
27
+ if (WRITE_OR_DDL.test(sql) && !/^EXPLAIN\b/i.test(sql)) {
28
+ // EXPLAIN may contain plan text; for normal SELECT/WITH still block DML keywords
29
+ if (!/^(SELECT|WITH|SHOW|DESCRIBE|DESC|PRAGMA)\b/i.test(sql)) {
30
+ throw new Error('write or DDL statements are not allowed');
31
+ }
32
+ // SELECT ... INTO is blocked by INTO in WRITE_OR_DDL
33
+ if (/\b(INSERT|UPDATE|DELETE|REPLACE|MERGE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE)\b/i.test(sql)) {
34
+ throw new Error('write or DDL statements are not allowed');
35
+ }
36
+ if (/\bINTO\b/i.test(sql) && !/\bINSERT\b/i.test(sql)) {
37
+ // SELECT INTO
38
+ throw new Error('SELECT INTO is not allowed');
39
+ }
40
+ }
41
+ if (/^PRAGMA\b/i.test(sql) && /\b(writable_schema|journal_mode\s*=|user_version\s*=)/i.test(sql)) {
42
+ throw new Error('mutating PRAGMA is not allowed');
43
+ }
44
+
45
+ const tables = extractTableNames(sql);
46
+ return { sql, tables };
47
+ };
48
+
49
+ /**
50
+ * Best-effort table name extraction from FROM / JOIN clauses.
51
+ */
52
+ const extractTableNames = sql => {
53
+ const names = new Set();
54
+ const re = /\b(?:FROM|JOIN)\s+([`"[\w.]+)/gi;
55
+ let match;
56
+ while ((match = re.exec(sql))) {
57
+ let raw = match[1];
58
+ raw = raw.replace(/^[`"[]|[`\]"]$/g, '');
59
+ const parts = raw.split('.');
60
+ const table = parts[parts.length - 1];
61
+ if (table && !/^\d+$/.test(table) && table.toLowerCase() !== 'select') {
62
+ names.add(table);
63
+ }
64
+ }
65
+ return [...names];
66
+ };
67
+
68
+ module.exports = {
69
+ assertReadOnlyQuerySql,
70
+ extractTableNames,
71
+ stripSqlComments
72
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -52,7 +52,7 @@
52
52
  "@fastify/env": "^5.0.2",
53
53
  "@fastify/sensible": "^6.0.5",
54
54
  "@kne/fastify-namespace": "^1.2.1",
55
- "@kne/fastify-sequelize": "^3.0.6",
55
+ "@kne/fastify-sequelize": "^4.0.3",
56
56
  "chai": "^6.2.2",
57
57
  "fastify": "^5.4.0",
58
58
  "fastify-plugin": "^5.0.1",