@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.
- package/README.md +80 -19
- package/index.js +29 -1
- package/libs/controllers/app.js +32 -4
- package/libs/controllers/dbops.js +268 -0
- package/libs/services/app.js +247 -48
- package/libs/services/bootstrap.js +10 -20
- package/libs/services/dbops.js +817 -0
- package/libs/utils/dbIdentity.js +118 -0
- package/libs/utils/env.js +110 -10
- package/libs/utils/logFiles.js +19 -5
- package/libs/utils/migrate.js +10 -5
- package/libs/utils/sqlQueryGuard.js +72 -0
- package/package.json +2 -2
|
@@ -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
|
|
14
|
-
const
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/libs/utils/logFiles.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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 {
|
|
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) => {
|
package/libs/utils/migrate.js
CHANGED
|
@@ -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
|
|
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.
|
|
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": "^
|
|
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",
|