@intranefr/superbackend 1.5.0 → 1.5.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/.env.example +15 -0
- package/README.md +11 -0
- package/analysis-only.skill +0 -0
- package/index.js +23 -0
- package/package.json +8 -2
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +90 -6
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminHeadless.controller.js +9 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +126 -4
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +80 -0
- package/src/helpers/mongooseHelper.js +258 -0
- package/src/helpers/scriptBase.js +230 -0
- package/src/helpers/scriptRunner.js +335 -0
- package/src/middleware/rbac.js +62 -0
- package/src/middleware.js +810 -48
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- package/src/models/Experiment.js +75 -0
- package/src/models/ExperimentAssignment.js +23 -0
- package/src/models/ExperimentEvent.js +26 -0
- package/src/models/ExperimentMetricBucket.js +30 -0
- package/src/models/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- package/src/models/ScriptDefinition.js +1 -0
- package/src/models/Webhook.js +2 -0
- package/src/routes/admin.routes.js +2 -0
- package/src/routes/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/adminHeadless.routes.js +2 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminUiComponents.routes.js +2 -1
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +1 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +185 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +738 -0
- package/src/services/consoleOverride.service.js +7 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- package/src/services/experiments.service.js +273 -0
- package/src/services/experimentsAggregation.service.js +308 -0
- package/src/services/experimentsCronsBootstrap.service.js +118 -0
- package/src/services/experimentsRetention.service.js +43 -0
- package/src/services/experimentsWs.service.js +134 -0
- package/src/services/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +215 -15
- package/src/services/uiComponentsAi.service.js +6 -19
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +33 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +163 -1
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +597 -3
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-ui-components.ejs +57 -25
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +12 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/views/partials/llm-provider-model-picker.ejs +183 -0
- package/src/routes/llmUi.routes.js +0 -26
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const ExternalDbConnection = require('../models/ExternalDbConnection');
|
|
4
|
+
const { encryptString, decryptString } = require('../utils/encryption');
|
|
5
|
+
|
|
6
|
+
// Cache adapters by connection id to avoid reconnecting on every request
|
|
7
|
+
const adapterCache = new Map();
|
|
8
|
+
|
|
9
|
+
function toSafeJsonError(error) {
|
|
10
|
+
const msg = error?.message || 'Operation failed';
|
|
11
|
+
const code = error?.code;
|
|
12
|
+
if (code === 'VALIDATION') return { status: 400, body: { error: msg } };
|
|
13
|
+
if (code === 'NOT_FOUND') return { status: 404, body: { error: msg } };
|
|
14
|
+
if (code === 'FORBIDDEN') return { status: 403, body: { error: msg } };
|
|
15
|
+
return { status: 500, body: { error: msg } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeName(name) {
|
|
19
|
+
const v = String(name || '').trim();
|
|
20
|
+
if (!v) throw Object.assign(new Error('name is required'), { code: 'VALIDATION' });
|
|
21
|
+
if (v.length > 120) throw Object.assign(new Error('name is too long'), { code: 'VALIDATION' });
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeType(type) {
|
|
26
|
+
const v = String(type || '').trim().toLowerCase();
|
|
27
|
+
if (!['mongo', 'mysql'].includes(v)) {
|
|
28
|
+
throw Object.assign(new Error('type must be mongo or mysql'), { code: 'VALIDATION' });
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeUri(uri) {
|
|
34
|
+
const v = String(uri || '').trim();
|
|
35
|
+
if (!v) throw Object.assign(new Error('uri is required'), { code: 'VALIDATION' });
|
|
36
|
+
if (v.length > 2000) throw Object.assign(new Error('uri is too long'), { code: 'VALIDATION' });
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function maskUri(uri) {
|
|
41
|
+
const raw = String(uri || '').trim();
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
|
|
44
|
+
// Mask user:pass for URIs like protocol://user:pass@host/... or protocol://user@host/...
|
|
45
|
+
// Keep protocol/host/query visible for debugging.
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(raw);
|
|
48
|
+
const hasCreds = Boolean(u.username || u.password);
|
|
49
|
+
if (hasCreds) {
|
|
50
|
+
u.username = u.username ? '***' : '';
|
|
51
|
+
u.password = u.password ? '***' : '';
|
|
52
|
+
}
|
|
53
|
+
return u.toString();
|
|
54
|
+
} catch {
|
|
55
|
+
// Fallback: regex mask the authority section.
|
|
56
|
+
return raw.replace(/:\/\/[^@/]+@/g, '://***@');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sanitizeConnectionDoc(doc) {
|
|
61
|
+
if (!doc) return null;
|
|
62
|
+
return {
|
|
63
|
+
id: String(doc._id),
|
|
64
|
+
name: doc.name,
|
|
65
|
+
type: doc.type,
|
|
66
|
+
enabled: Boolean(doc.enabled),
|
|
67
|
+
uriMasked: doc.uriMasked || null,
|
|
68
|
+
createdAt: doc.createdAt,
|
|
69
|
+
updatedAt: doc.updatedAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseIntBounded(v, { min = 1, max = 100 } = {}) {
|
|
74
|
+
const n = parseInt(v, 10);
|
|
75
|
+
if (Number.isNaN(n)) return min;
|
|
76
|
+
return Math.min(max, Math.max(min, n));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeIdentifier(v, { label = 'identifier' } = {}) {
|
|
80
|
+
const s = String(v || '').trim();
|
|
81
|
+
if (!s) throw Object.assign(new Error(`${label} is required`), { code: 'VALIDATION' });
|
|
82
|
+
// Conservative: allow alphanumerics, underscore and dash.
|
|
83
|
+
if (!/^[A-Za-z0-9_\-]+$/.test(s)) {
|
|
84
|
+
throw Object.assign(new Error(`Invalid ${label}`), { code: 'VALIDATION' });
|
|
85
|
+
}
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeMongoField(v) {
|
|
90
|
+
const s = String(v || '').trim();
|
|
91
|
+
if (!s) return null;
|
|
92
|
+
// allow dot paths, prevent $ operators
|
|
93
|
+
if (s.includes('$')) {
|
|
94
|
+
throw Object.assign(new Error('Invalid field'), { code: 'VALIDATION' });
|
|
95
|
+
}
|
|
96
|
+
if (!/^[A-Za-z0-9_\.\-]+$/.test(s)) {
|
|
97
|
+
throw Object.assign(new Error('Invalid field'), { code: 'VALIDATION' });
|
|
98
|
+
}
|
|
99
|
+
return s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeMongoNamespace(v, { label = 'collection', maxLen = 255 } = {}) {
|
|
103
|
+
const s = String(v || '').trim();
|
|
104
|
+
if (!s) throw Object.assign(new Error(`${label} is required`), { code: 'VALIDATION' });
|
|
105
|
+
if (s.length > maxLen) throw Object.assign(new Error(`${label} is too long`), { code: 'VALIDATION' });
|
|
106
|
+
// Disallow null bytes and operator-ish names
|
|
107
|
+
if (s.includes('\u0000') || s.includes('\0') || s.includes('\x00') || s.includes(String.fromCharCode(0)) || s.includes('$')) {
|
|
108
|
+
throw Object.assign(new Error(`Invalid ${label}`), { code: 'VALIDATION' });
|
|
109
|
+
}
|
|
110
|
+
// Allow common collection characters, including dots.
|
|
111
|
+
if (!/^[A-Za-z0-9_\.\-]+$/.test(s)) {
|
|
112
|
+
throw Object.assign(new Error(`Invalid ${label}`), { code: 'VALIDATION' });
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function escapeMongoRegexLiteral(value) {
|
|
118
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getEnabledConnection(connectionId) {
|
|
122
|
+
const doc = await ExternalDbConnection.findById(connectionId);
|
|
123
|
+
if (!doc) throw Object.assign(new Error('Connection not found'), { code: 'NOT_FOUND' });
|
|
124
|
+
if (!doc.enabled) throw Object.assign(new Error('Connection is disabled'), { code: 'FORBIDDEN' });
|
|
125
|
+
return doc;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function getAdapter(connectionId) {
|
|
129
|
+
const doc = await getEnabledConnection(connectionId);
|
|
130
|
+
const updatedAtMs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : 0;
|
|
131
|
+
|
|
132
|
+
const cached = adapterCache.get(String(doc._id));
|
|
133
|
+
if (cached && cached.updatedAtMs === updatedAtMs && cached.type === doc.type) {
|
|
134
|
+
return cached.adapter;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (cached?.adapter?.close) {
|
|
138
|
+
try {
|
|
139
|
+
await cached.adapter.close();
|
|
140
|
+
} catch {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const uri = decryptString(doc.uriEncrypted);
|
|
146
|
+
const adapter = doc.type === 'mongo' ? await createMongoAdapter(uri) : await createMysqlAdapter(uri);
|
|
147
|
+
adapterCache.set(String(doc._id), { adapter, type: doc.type, updatedAtMs });
|
|
148
|
+
return adapter;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function createMongoAdapter(uri) {
|
|
152
|
+
const conn = mongoose.createConnection(uri, {
|
|
153
|
+
serverSelectionTimeoutMS: 8000,
|
|
154
|
+
maxPoolSize: 5,
|
|
155
|
+
});
|
|
156
|
+
await conn.asPromise();
|
|
157
|
+
|
|
158
|
+
async function useDb(database) {
|
|
159
|
+
const dbName = normalizeIdentifier(database, { label: 'database' });
|
|
160
|
+
const dbConn = conn.useDb(dbName, { useCache: true });
|
|
161
|
+
await dbConn.asPromise().catch(() => {});
|
|
162
|
+
return dbConn;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
type: 'mongo',
|
|
167
|
+
async close() {
|
|
168
|
+
await conn.close();
|
|
169
|
+
},
|
|
170
|
+
async testConnection() {
|
|
171
|
+
await conn.db.admin().ping();
|
|
172
|
+
return { ok: true };
|
|
173
|
+
},
|
|
174
|
+
async listDatabases() {
|
|
175
|
+
const out = await conn.db.admin().listDatabases();
|
|
176
|
+
const names = (out?.databases || []).map((d) => d.name).filter(Boolean).sort();
|
|
177
|
+
return names;
|
|
178
|
+
},
|
|
179
|
+
async listNamespaces({ database }) {
|
|
180
|
+
const dbConn = await useDb(database);
|
|
181
|
+
const cols = await dbConn.db.listCollections({}, { nameOnly: true }).toArray();
|
|
182
|
+
return cols.map((c) => c.name).filter(Boolean).sort();
|
|
183
|
+
},
|
|
184
|
+
async getSchema() {
|
|
185
|
+
// Mongo doesn't have a rigid schema; v1 returns null.
|
|
186
|
+
return null;
|
|
187
|
+
},
|
|
188
|
+
async listRecords({ database, namespace, page = 1, pageSize = 20, filterField, filterValue, sortField, sortOrder }) {
|
|
189
|
+
const dbConn = await useDb(database);
|
|
190
|
+
const collection = normalizeMongoNamespace(namespace, { label: 'collection' });
|
|
191
|
+
|
|
192
|
+
const safePage = parseIntBounded(page, { min: 1, max: 1000000 });
|
|
193
|
+
const safePageSize = parseIntBounded(pageSize, { min: 1, max: 100 });
|
|
194
|
+
const skip = (safePage - 1) * safePageSize;
|
|
195
|
+
|
|
196
|
+
const query = {};
|
|
197
|
+
const f = normalizeMongoField(filterField);
|
|
198
|
+
const fv = String(filterValue || '').trim();
|
|
199
|
+
if (f && fv) {
|
|
200
|
+
if (f === '_id' && mongoose.isValidObjectId(fv)) {
|
|
201
|
+
query._id = new mongoose.Types.ObjectId(fv);
|
|
202
|
+
} else {
|
|
203
|
+
query[f] = { $regex: escapeMongoRegexLiteral(fv), $options: 'i' };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sort = {};
|
|
208
|
+
const sField = normalizeMongoField(sortField);
|
|
209
|
+
if (sField) {
|
|
210
|
+
sort[sField] = String(sortOrder).toLowerCase() === 'asc' ? 1 : -1;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const col = dbConn.db.collection(collection);
|
|
214
|
+
const [items, total] = await Promise.all([
|
|
215
|
+
col
|
|
216
|
+
.find(query)
|
|
217
|
+
.sort(Object.keys(sort).length ? sort : undefined)
|
|
218
|
+
.skip(skip)
|
|
219
|
+
.limit(safePageSize)
|
|
220
|
+
.toArray(),
|
|
221
|
+
col.countDocuments(query),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
items,
|
|
226
|
+
total,
|
|
227
|
+
page: safePage,
|
|
228
|
+
pageSize: safePageSize,
|
|
229
|
+
totalPages: Math.ceil(total / safePageSize),
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
async getRecord({ database, namespace, id }) {
|
|
233
|
+
const dbConn = await useDb(database);
|
|
234
|
+
const collection = normalizeMongoNamespace(namespace, { label: 'collection' });
|
|
235
|
+
const rawId = String(id || '').trim();
|
|
236
|
+
if (!rawId) throw Object.assign(new Error('id is required'), { code: 'VALIDATION' });
|
|
237
|
+
|
|
238
|
+
const query = mongoose.isValidObjectId(rawId)
|
|
239
|
+
? { _id: new mongoose.Types.ObjectId(rawId) }
|
|
240
|
+
: { _id: rawId };
|
|
241
|
+
|
|
242
|
+
const item = await dbConn.db.collection(collection).findOne(query);
|
|
243
|
+
if (!item) throw Object.assign(new Error('Record not found'), { code: 'NOT_FOUND' });
|
|
244
|
+
return item;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function createMysqlAdapter(uri) {
|
|
250
|
+
let mysql;
|
|
251
|
+
try {
|
|
252
|
+
mysql = require('mysql2/promise');
|
|
253
|
+
} catch {
|
|
254
|
+
throw Object.assign(
|
|
255
|
+
new Error('MySQL browsing requires the "mysql2" package. Please add it to dependencies.'),
|
|
256
|
+
{ code: 'VALIDATION' },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const pool = mysql.createPool(uri);
|
|
261
|
+
|
|
262
|
+
function normalizeSqlIdentifier(v, { label = 'identifier', maxLen = 128 } = {}) {
|
|
263
|
+
const s = String(v || '');
|
|
264
|
+
const trimmed = s.trim();
|
|
265
|
+
if (!trimmed) throw Object.assign(new Error(`${label} is required`), { code: 'VALIDATION' });
|
|
266
|
+
if (trimmed.length > maxLen) {
|
|
267
|
+
throw Object.assign(new Error(`${label} is too long`), { code: 'VALIDATION' });
|
|
268
|
+
}
|
|
269
|
+
if (trimmed.includes('\u0000') || trimmed.includes('\0') || trimmed.includes('\x00') || trimmed.includes(String.fromCharCode(0))) {
|
|
270
|
+
throw Object.assign(new Error(`Invalid ${label}`), { code: 'VALIDATION' });
|
|
271
|
+
}
|
|
272
|
+
return trimmed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function escapeId(id) {
|
|
276
|
+
// MySQL identifier quoting: wrap in backticks and escape internal backticks by doubling them.
|
|
277
|
+
const safe = normalizeSqlIdentifier(id);
|
|
278
|
+
return `\`${safe.replace(/`/g, '``')}\``;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeSqlValue(v, { label = 'value', maxLen = 2000 } = {}) {
|
|
282
|
+
const s = String(v || '').trim();
|
|
283
|
+
if (!s) throw Object.assign(new Error(`${label} is required`), { code: 'VALIDATION' });
|
|
284
|
+
if (s.length > maxLen) throw Object.assign(new Error(`${label} is too long`), { code: 'VALIDATION' });
|
|
285
|
+
if (s.includes('\u0000') || s.includes('\0') || s.includes('\x00') || s.includes(String.fromCharCode(0))) {
|
|
286
|
+
throw Object.assign(new Error(`Invalid ${label}`), { code: 'VALIDATION' });
|
|
287
|
+
}
|
|
288
|
+
return s;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
type: 'mysql',
|
|
293
|
+
async close() {
|
|
294
|
+
await pool.end();
|
|
295
|
+
},
|
|
296
|
+
async testConnection() {
|
|
297
|
+
const conn = await pool.getConnection();
|
|
298
|
+
try {
|
|
299
|
+
await conn.ping();
|
|
300
|
+
} finally {
|
|
301
|
+
conn.release();
|
|
302
|
+
}
|
|
303
|
+
return { ok: true };
|
|
304
|
+
},
|
|
305
|
+
async listDatabases() {
|
|
306
|
+
const [rows] = await pool.query('SHOW DATABASES');
|
|
307
|
+
const names = (rows || [])
|
|
308
|
+
.map((r) => r.Database)
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
.sort();
|
|
311
|
+
return names;
|
|
312
|
+
},
|
|
313
|
+
async listNamespaces({ database }) {
|
|
314
|
+
const db = normalizeSqlIdentifier(database, { label: 'database' });
|
|
315
|
+
const [rows] = await pool.query(`SHOW TABLES FROM ${escapeId(db)}`);
|
|
316
|
+
const names = (rows || [])
|
|
317
|
+
.map((r) => r[Object.keys(r)[0]])
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.sort();
|
|
320
|
+
return names;
|
|
321
|
+
},
|
|
322
|
+
async getSchema({ database, namespace }) {
|
|
323
|
+
const db = normalizeSqlIdentifier(database, { label: 'database' });
|
|
324
|
+
const table = normalizeSqlIdentifier(namespace, { label: 'table' });
|
|
325
|
+
const [rows] = await pool.query(
|
|
326
|
+
`SHOW COLUMNS FROM ${escapeId(db)}.${escapeId(table)}`,
|
|
327
|
+
);
|
|
328
|
+
return (rows || []).map((r) => ({
|
|
329
|
+
field: r.Field,
|
|
330
|
+
type: r.Type,
|
|
331
|
+
nullable: r.Null,
|
|
332
|
+
key: r.Key,
|
|
333
|
+
default: r.Default,
|
|
334
|
+
extra: r.Extra,
|
|
335
|
+
}));
|
|
336
|
+
},
|
|
337
|
+
async listRecords({ database, namespace, page = 1, pageSize = 20, filterField, filterValue, sortField, sortOrder }) {
|
|
338
|
+
const db = normalizeSqlIdentifier(database, { label: 'database' });
|
|
339
|
+
const table = normalizeSqlIdentifier(namespace, { label: 'table' });
|
|
340
|
+
|
|
341
|
+
const safePage = parseIntBounded(page, { min: 1, max: 1000000 });
|
|
342
|
+
const safePageSize = parseIntBounded(pageSize, { min: 1, max: 100 });
|
|
343
|
+
const offset = (safePage - 1) * safePageSize;
|
|
344
|
+
|
|
345
|
+
const schema = await this.getSchema({ database: db, namespace: table });
|
|
346
|
+
const columns = new Set(schema.map((c) => c.field).filter(Boolean));
|
|
347
|
+
|
|
348
|
+
const where = [];
|
|
349
|
+
const params = [];
|
|
350
|
+
|
|
351
|
+
const f = String(filterField || '').trim();
|
|
352
|
+
const fv = String(filterValue || '').trim();
|
|
353
|
+
if (f && fv) {
|
|
354
|
+
if (!columns.has(f)) {
|
|
355
|
+
throw Object.assign(new Error('Invalid filter field'), { code: 'VALIDATION' });
|
|
356
|
+
}
|
|
357
|
+
// `f` is validated against actual columns, then safely quoted.
|
|
358
|
+
where.push(`${escapeId(f)} LIKE ?`);
|
|
359
|
+
params.push(`%${fv}%`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const s = String(sortField || '').trim();
|
|
363
|
+
let orderBy = '';
|
|
364
|
+
if (s) {
|
|
365
|
+
if (!columns.has(s)) {
|
|
366
|
+
throw Object.assign(new Error('Invalid sort field'), { code: 'VALIDATION' });
|
|
367
|
+
}
|
|
368
|
+
orderBy = ` ORDER BY ${escapeId(s)} ${String(sortOrder).toLowerCase() === 'asc' ? 'ASC' : 'DESC'}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : '';
|
|
372
|
+
const fromSql = `${escapeId(db)}.${escapeId(table)}`;
|
|
373
|
+
|
|
374
|
+
const [itemsRows, countRows] = await Promise.all([
|
|
375
|
+
pool
|
|
376
|
+
.query(
|
|
377
|
+
`SELECT * FROM ${fromSql}${whereSql}${orderBy} LIMIT ? OFFSET ?`,
|
|
378
|
+
[...params, safePageSize, offset],
|
|
379
|
+
)
|
|
380
|
+
.then((r) => r[0]),
|
|
381
|
+
pool
|
|
382
|
+
.query(
|
|
383
|
+
`SELECT COUNT(*) AS cnt FROM ${fromSql}${whereSql}`,
|
|
384
|
+
params,
|
|
385
|
+
)
|
|
386
|
+
.then((r) => r[0]),
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
const total = Number(countRows?.[0]?.cnt || 0);
|
|
390
|
+
return {
|
|
391
|
+
items: itemsRows || [],
|
|
392
|
+
total,
|
|
393
|
+
page: safePage,
|
|
394
|
+
pageSize: safePageSize,
|
|
395
|
+
totalPages: Math.ceil(total / safePageSize),
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
async getRecord({ database, namespace, id }) {
|
|
399
|
+
const db = normalizeSqlIdentifier(database, { label: 'database' });
|
|
400
|
+
const table = normalizeSqlIdentifier(namespace, { label: 'table' });
|
|
401
|
+
const recordId = normalizeSqlValue(id, { label: 'recordId' });
|
|
402
|
+
|
|
403
|
+
const schema = await this.getSchema({ database: db, namespace: table });
|
|
404
|
+
const pkCols = (schema || []).filter((c) => String(c?.key || '').toUpperCase() === 'PRI').map((c) => c.field).filter(Boolean);
|
|
405
|
+
const columns = new Set((schema || []).map((c) => c.field).filter(Boolean));
|
|
406
|
+
|
|
407
|
+
let pk = null;
|
|
408
|
+
if (pkCols.length === 1) {
|
|
409
|
+
pk = pkCols[0];
|
|
410
|
+
} else if (pkCols.length === 0 && columns.has('id')) {
|
|
411
|
+
// Fallback: common convention
|
|
412
|
+
pk = 'id';
|
|
413
|
+
}
|
|
414
|
+
if (!pk) {
|
|
415
|
+
throw Object.assign(new Error('Could not determine a single primary key column for this table'), { code: 'VALIDATION' });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const fromSql = `${escapeId(db)}.${escapeId(table)}`;
|
|
419
|
+
const [rows] = await pool.query(
|
|
420
|
+
`SELECT * FROM ${fromSql} WHERE ${escapeId(pk)} = ? LIMIT 1`,
|
|
421
|
+
[recordId],
|
|
422
|
+
);
|
|
423
|
+
const item = rows?.[0] || null;
|
|
424
|
+
if (!item) throw Object.assign(new Error('Record not found'), { code: 'NOT_FOUND' });
|
|
425
|
+
return item;
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// CRUD: connection profiles
|
|
431
|
+
async function listConnections() {
|
|
432
|
+
const docs = await ExternalDbConnection.find({}).sort({ createdAt: -1 }).lean();
|
|
433
|
+
return docs.map(sanitizeConnectionDoc);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function getConnection(connectionId) {
|
|
437
|
+
const doc = await ExternalDbConnection.findById(connectionId);
|
|
438
|
+
if (!doc) throw Object.assign(new Error('Connection not found'), { code: 'NOT_FOUND' });
|
|
439
|
+
return sanitizeConnectionDoc(doc);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function createConnection(payload = {}) {
|
|
443
|
+
const name = normalizeName(payload.name);
|
|
444
|
+
const type = normalizeType(payload.type);
|
|
445
|
+
const uri = normalizeUri(payload.uri);
|
|
446
|
+
const enabled = payload.enabled === undefined ? true : Boolean(payload.enabled);
|
|
447
|
+
|
|
448
|
+
const doc = await ExternalDbConnection.create({
|
|
449
|
+
name,
|
|
450
|
+
type,
|
|
451
|
+
enabled,
|
|
452
|
+
uriMasked: maskUri(uri),
|
|
453
|
+
uriEncrypted: encryptString(uri),
|
|
454
|
+
});
|
|
455
|
+
return sanitizeConnectionDoc(doc);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function updateConnection(connectionId, payload = {}) {
|
|
459
|
+
const doc = await ExternalDbConnection.findById(connectionId);
|
|
460
|
+
if (!doc) throw Object.assign(new Error('Connection not found'), { code: 'NOT_FOUND' });
|
|
461
|
+
|
|
462
|
+
if (payload.name !== undefined) doc.name = normalizeName(payload.name);
|
|
463
|
+
if (payload.type !== undefined) doc.type = normalizeType(payload.type);
|
|
464
|
+
if (payload.enabled !== undefined) doc.enabled = Boolean(payload.enabled);
|
|
465
|
+
|
|
466
|
+
if (payload.uri !== undefined) {
|
|
467
|
+
const uri = normalizeUri(payload.uri);
|
|
468
|
+
doc.uriMasked = maskUri(uri);
|
|
469
|
+
doc.uriEncrypted = encryptString(uri);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
await doc.save();
|
|
473
|
+
return sanitizeConnectionDoc(doc);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function deleteConnection(connectionId) {
|
|
477
|
+
const doc = await ExternalDbConnection.findByIdAndDelete(connectionId);
|
|
478
|
+
if (!doc) throw Object.assign(new Error('Connection not found'), { code: 'NOT_FOUND' });
|
|
479
|
+
|
|
480
|
+
const cached = adapterCache.get(String(connectionId));
|
|
481
|
+
if (cached?.adapter?.close) {
|
|
482
|
+
try {
|
|
483
|
+
await cached.adapter.close();
|
|
484
|
+
} catch {
|
|
485
|
+
// ignore
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
adapterCache.delete(String(connectionId));
|
|
489
|
+
return { ok: true };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function testConnection(connectionId) {
|
|
493
|
+
const adapter = await getAdapter(connectionId);
|
|
494
|
+
return adapter.testConnection();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Browsing
|
|
498
|
+
async function listDatabases(connectionId) {
|
|
499
|
+
const adapter = await getAdapter(connectionId);
|
|
500
|
+
return adapter.listDatabases();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function listNamespaces(connectionId, database) {
|
|
504
|
+
const adapter = await getAdapter(connectionId);
|
|
505
|
+
return adapter.listNamespaces({ database });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function getSchema(connectionId, database, namespace) {
|
|
509
|
+
const adapter = await getAdapter(connectionId);
|
|
510
|
+
return adapter.getSchema({ database, namespace });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function listRecords(connectionId, database, namespace, options = {}) {
|
|
514
|
+
const adapter = await getAdapter(connectionId);
|
|
515
|
+
return adapter.listRecords({ database, namespace, ...options });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function getRecord(connectionId, database, namespace, id) {
|
|
519
|
+
const adapter = await getAdapter(connectionId);
|
|
520
|
+
return adapter.getRecord({ database, namespace, id });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
module.exports = {
|
|
524
|
+
toSafeJsonError,
|
|
525
|
+
listConnections,
|
|
526
|
+
getConnection,
|
|
527
|
+
createConnection,
|
|
528
|
+
updateConnection,
|
|
529
|
+
deleteConnection,
|
|
530
|
+
testConnection,
|
|
531
|
+
listDatabases,
|
|
532
|
+
listNamespaces,
|
|
533
|
+
getSchema,
|
|
534
|
+
listRecords,
|
|
535
|
+
getRecord,
|
|
536
|
+
};
|