@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const GlobalSetting = require('../models/GlobalSetting');
|
|
6
|
+
const { encryptString, decryptString } = require('../utils/encryption');
|
|
7
|
+
const objectStorage = require('./objectStorage.service');
|
|
8
|
+
const migrationAssets = require('./migrationAssets');
|
|
9
|
+
const { createFsLocalEndpoint } = require('./migrationAssets/fsLocal');
|
|
10
|
+
const { createS3Endpoint } = require('./migrationAssets/s3');
|
|
11
|
+
|
|
12
|
+
const ENV_PREFIX = 'ENV_CONF_';
|
|
13
|
+
|
|
14
|
+
const connections = new Map();
|
|
15
|
+
|
|
16
|
+
function normalizeEnvKey(key) {
|
|
17
|
+
const raw = String(key || '').trim();
|
|
18
|
+
if (!raw) return null;
|
|
19
|
+
return raw.startsWith(ENV_PREFIX) ? raw : `${ENV_PREFIX}${raw}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safeParseJson(str) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(String(str));
|
|
25
|
+
} catch (_) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function redactConnectionString(conn) {
|
|
31
|
+
const raw = String(conn || '').trim();
|
|
32
|
+
if (!raw) return '';
|
|
33
|
+
return raw.length <= 12 ? '********' : `${raw.slice(0, 6)}********${raw.slice(-4)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function listEnvironments() {
|
|
37
|
+
const settings = await GlobalSetting.find({ key: { $regex: `^${ENV_PREFIX}` } })
|
|
38
|
+
.sort({ key: 1 })
|
|
39
|
+
.lean();
|
|
40
|
+
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const s of settings) {
|
|
43
|
+
try {
|
|
44
|
+
if (s.type !== 'encrypted') continue;
|
|
45
|
+
const payload = safeParseJson(s.value);
|
|
46
|
+
const plaintext = payload ? decryptString(payload) : null;
|
|
47
|
+
const cfg = plaintext ? safeParseJson(plaintext) : null;
|
|
48
|
+
if (!cfg) continue;
|
|
49
|
+
|
|
50
|
+
out.push({
|
|
51
|
+
key: s.key,
|
|
52
|
+
name: String(cfg.name || s.key.replace(ENV_PREFIX, '')),
|
|
53
|
+
description: cfg.description ? String(cfg.description) : '',
|
|
54
|
+
connectionStringMasked: redactConnectionString(cfg.connectionString),
|
|
55
|
+
assetsTargetType: cfg?.assets?.target?.type ? String(cfg.assets.target.type) : '',
|
|
56
|
+
updatedAt: s.updatedAt,
|
|
57
|
+
createdAt: s.createdAt,
|
|
58
|
+
});
|
|
59
|
+
} catch (_) {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function getEnvironmentConfig(envKey) {
|
|
68
|
+
const key = normalizeEnvKey(envKey);
|
|
69
|
+
if (!key) return null;
|
|
70
|
+
|
|
71
|
+
const setting = await GlobalSetting.findOne({ key }).lean();
|
|
72
|
+
if (!setting) return null;
|
|
73
|
+
if (setting.type !== 'encrypted') return null;
|
|
74
|
+
|
|
75
|
+
const payload = safeParseJson(setting.value);
|
|
76
|
+
const plaintext = payload ? decryptString(payload) : null;
|
|
77
|
+
const cfg = plaintext ? safeParseJson(plaintext) : null;
|
|
78
|
+
if (!cfg) return null;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
key,
|
|
82
|
+
name: String(cfg.name || key.replace(ENV_PREFIX, '')),
|
|
83
|
+
description: cfg.description ? String(cfg.description) : '',
|
|
84
|
+
connectionString: String(cfg.connectionString || ''),
|
|
85
|
+
assets: cfg?.assets && typeof cfg.assets === 'object' ? cfg.assets : undefined,
|
|
86
|
+
// convenience alias for UIs
|
|
87
|
+
assetsTarget: cfg?.assets?.target && typeof cfg.assets.target === 'object' ? cfg.assets.target : undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeAssetsTarget(target) {
|
|
92
|
+
// Default to local filesystem if nothing provided
|
|
93
|
+
if (!target || typeof target !== 'object') {
|
|
94
|
+
return {
|
|
95
|
+
type: 'fs_local',
|
|
96
|
+
fs: {
|
|
97
|
+
baseDir: process.env.UPLOAD_DIR || 'uploads',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const type = String(target.type || '').trim();
|
|
102
|
+
if (!type) {
|
|
103
|
+
return {
|
|
104
|
+
type: 'fs_local',
|
|
105
|
+
fs: {
|
|
106
|
+
baseDir: process.env.UPLOAD_DIR || 'uploads',
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (type === 'fs_local') {
|
|
112
|
+
const baseDir = target?.fs?.baseDir ? String(target.fs.baseDir).trim() : '';
|
|
113
|
+
return {
|
|
114
|
+
type,
|
|
115
|
+
fs: {
|
|
116
|
+
baseDir: baseDir || (process.env.UPLOAD_DIR || 'uploads'),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (type === 'fs_remote') {
|
|
122
|
+
const host = String(target?.ssh?.host || '').trim();
|
|
123
|
+
const username = String(target?.ssh?.username || '').trim();
|
|
124
|
+
const privateKeyPem = String(target?.ssh?.privateKeyPem || '').trim();
|
|
125
|
+
const baseDir = String(target?.ssh?.baseDir || '').trim();
|
|
126
|
+
const port = target?.ssh?.port ? Number(target.ssh.port) : 22;
|
|
127
|
+
const passphrase = target?.ssh?.passphrase ? String(target.ssh.passphrase) : '';
|
|
128
|
+
|
|
129
|
+
if (!host || !username || !privateKeyPem || !baseDir) {
|
|
130
|
+
const err = new Error('Invalid fs_remote assets config (host, username, privateKeyPem, baseDir required)');
|
|
131
|
+
err.status = 400;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
type,
|
|
137
|
+
ssh: {
|
|
138
|
+
host,
|
|
139
|
+
port,
|
|
140
|
+
username,
|
|
141
|
+
privateKeyPem,
|
|
142
|
+
passphrase: passphrase || undefined,
|
|
143
|
+
baseDir,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (type === 's3') {
|
|
149
|
+
const endpoint = String(target?.s3?.endpoint || '').trim();
|
|
150
|
+
const region = String(target?.s3?.region || 'us-east-1').trim() || 'us-east-1';
|
|
151
|
+
const bucket = String(target?.s3?.bucket || '').trim();
|
|
152
|
+
const accessKeyId = String(target?.s3?.accessKeyId || '').trim();
|
|
153
|
+
const secretAccessKey = String(target?.s3?.secretAccessKey || '').trim();
|
|
154
|
+
const forcePathStyle = Boolean(target?.s3?.forcePathStyle);
|
|
155
|
+
|
|
156
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
157
|
+
const err = new Error('Invalid s3 assets config (endpoint, bucket, accessKeyId, secretAccessKey required)');
|
|
158
|
+
err.status = 400;
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
type,
|
|
164
|
+
s3: {
|
|
165
|
+
endpoint,
|
|
166
|
+
region,
|
|
167
|
+
bucket,
|
|
168
|
+
accessKeyId,
|
|
169
|
+
secretAccessKey,
|
|
170
|
+
forcePathStyle,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const err = new Error('Unsupported assets target type');
|
|
176
|
+
err.status = 400;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function upsertEnvironment(envKey, { name, connectionString, description, assetsTarget } = {}) {
|
|
181
|
+
const key = normalizeEnvKey(envKey);
|
|
182
|
+
if (!key) {
|
|
183
|
+
const err = new Error('envKey is required');
|
|
184
|
+
err.status = 400;
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const safeName = String(name || key.replace(ENV_PREFIX, '')).trim();
|
|
189
|
+
let safeConn = String(connectionString || '').trim();
|
|
190
|
+
const safeDescription = description ? String(description).trim() : '';
|
|
191
|
+
|
|
192
|
+
const normalizedAssetsTarget = normalizeAssetsTarget(assetsTarget);
|
|
193
|
+
|
|
194
|
+
if (!safeConn) {
|
|
195
|
+
// allow updating an existing env without re-sending the conn string
|
|
196
|
+
const existing = await getEnvironmentConfig(key);
|
|
197
|
+
if (existing?.connectionString) {
|
|
198
|
+
safeConn = existing.connectionString;
|
|
199
|
+
} else {
|
|
200
|
+
const err = new Error('connectionString is required');
|
|
201
|
+
err.status = 400;
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const payload = {
|
|
207
|
+
name: safeName,
|
|
208
|
+
connectionString: safeConn,
|
|
209
|
+
description: safeDescription,
|
|
210
|
+
assets: normalizedAssetsTarget ? { target: normalizedAssetsTarget } : undefined,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const encryptedPayload = encryptString(JSON.stringify(payload));
|
|
214
|
+
const storedValue = JSON.stringify(encryptedPayload);
|
|
215
|
+
|
|
216
|
+
const doc = await GlobalSetting.findOneAndUpdate(
|
|
217
|
+
{ key },
|
|
218
|
+
{
|
|
219
|
+
key,
|
|
220
|
+
value: storedValue,
|
|
221
|
+
type: 'encrypted',
|
|
222
|
+
description: 'Migration environment config',
|
|
223
|
+
public: false,
|
|
224
|
+
templateVariables: [],
|
|
225
|
+
},
|
|
226
|
+
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
|
227
|
+
).lean();
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
key: doc.key,
|
|
231
|
+
name: safeName,
|
|
232
|
+
description: safeDescription,
|
|
233
|
+
connectionStringMasked: redactConnectionString(safeConn),
|
|
234
|
+
createdAt: doc.createdAt,
|
|
235
|
+
updatedAt: doc.updatedAt,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function deleteEnvironment(envKey) {
|
|
240
|
+
const key = normalizeEnvKey(envKey);
|
|
241
|
+
if (!key) {
|
|
242
|
+
const err = new Error('envKey is required');
|
|
243
|
+
err.status = 400;
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const deleted = await GlobalSetting.findOneAndDelete({ key }).lean();
|
|
248
|
+
if (!deleted) {
|
|
249
|
+
const err = new Error('Environment not found');
|
|
250
|
+
err.status = 404;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
connections.delete(key);
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function getSettingValueFromConn(conn, key, defaultValue = null) {
|
|
258
|
+
if (!conn) return defaultValue;
|
|
259
|
+
const Model = conn.model('GlobalSetting', GlobalSetting.schema);
|
|
260
|
+
const doc = await Model.findOne({ key }).lean();
|
|
261
|
+
if (!doc) return defaultValue;
|
|
262
|
+
if (doc.type !== 'encrypted') return doc.value;
|
|
263
|
+
const payload = safeParseJson(doc.value);
|
|
264
|
+
if (!payload) return defaultValue;
|
|
265
|
+
try {
|
|
266
|
+
return decryptString(payload);
|
|
267
|
+
} catch (_) {
|
|
268
|
+
return defaultValue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function resolveTargetStorage(targetConn) {
|
|
273
|
+
const backendRaw = await getSettingValueFromConn(targetConn, 'STORAGE_BACKEND', null);
|
|
274
|
+
const backend = String(backendRaw || '').trim().toLowerCase();
|
|
275
|
+
|
|
276
|
+
const s3ConfigRaw = await getSettingValueFromConn(targetConn, 'STORAGE_S3_CONFIG', null);
|
|
277
|
+
const parsedS3 = s3ConfigRaw ? safeParseJson(String(s3ConfigRaw)) : null;
|
|
278
|
+
const validS3 = parsedS3
|
|
279
|
+
&& typeof parsedS3 === 'object'
|
|
280
|
+
&& String(parsedS3.endpoint || '').trim()
|
|
281
|
+
&& String(parsedS3.accessKeyId || '').trim()
|
|
282
|
+
&& String(parsedS3.secretAccessKey || '').trim()
|
|
283
|
+
&& String(parsedS3.bucket || '').trim();
|
|
284
|
+
|
|
285
|
+
if (backend === 's3') {
|
|
286
|
+
return { backend: 's3', s3: validS3 ? parsedS3 : null };
|
|
287
|
+
}
|
|
288
|
+
if (backend === 'fs') {
|
|
289
|
+
return { backend: 'fs', s3: null };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (validS3) {
|
|
293
|
+
return { backend: 's3', s3: parsedS3 };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { backend: 'fs', s3: null };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function resolveTargetAssetEndpoint(targetEnvKey) {
|
|
300
|
+
const envCfg = await getEnvironmentConfig(targetEnvKey);
|
|
301
|
+
const fromEnv = await migrationAssets.resolveTargetEndpointFromEnvConfig(envCfg);
|
|
302
|
+
if (fromEnv) return fromEnv;
|
|
303
|
+
|
|
304
|
+
const targetConn = await getTargetConnection(targetEnvKey);
|
|
305
|
+
const targetStorage = await resolveTargetStorage(targetConn);
|
|
306
|
+
if (targetStorage.backend === 's3' && targetStorage.s3) {
|
|
307
|
+
return createS3Endpoint(targetStorage.s3);
|
|
308
|
+
}
|
|
309
|
+
return createFsLocalEndpoint({ baseDir: process.env.UPLOAD_DIR || 'uploads' });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function testAssetsTarget({ targetEnvKey } = {}) {
|
|
313
|
+
if (!targetEnvKey) {
|
|
314
|
+
const err = new Error('targetEnvKey is required');
|
|
315
|
+
err.status = 400;
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
const endpoint = await resolveTargetAssetEndpoint(targetEnvKey);
|
|
319
|
+
const result = await endpoint.testWritable();
|
|
320
|
+
return { ok: true, endpointType: endpoint.type, result };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function testAssetsCopyKey({ targetEnvKey, key, dryRun = false } = {}) {
|
|
324
|
+
const safeKey = String(key || '').trim();
|
|
325
|
+
if (!targetEnvKey) {
|
|
326
|
+
const err = new Error('targetEnvKey is required');
|
|
327
|
+
err.status = 400;
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
if (!safeKey) {
|
|
331
|
+
const err = new Error('key is required');
|
|
332
|
+
err.status = 400;
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sourceEndpoint = await migrationAssets.resolveSourceEndpoint();
|
|
337
|
+
const targetEndpoint = await resolveTargetAssetEndpoint(targetEnvKey);
|
|
338
|
+
const copy = await migrationAssets.copyKeys({
|
|
339
|
+
keys: [safeKey],
|
|
340
|
+
sourceEndpoint,
|
|
341
|
+
targetEndpoint,
|
|
342
|
+
dryRun: !!dryRun,
|
|
343
|
+
batchSize: 1,
|
|
344
|
+
});
|
|
345
|
+
return { ok: copy.ok, copy };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function copyAssetKeys({ targetEnvKey, keys, dryRun = false, batchSize = 10 } = {}) {
|
|
349
|
+
if (!targetEnvKey) {
|
|
350
|
+
const err = new Error('targetEnvKey is required');
|
|
351
|
+
err.status = 400;
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
const list = Array.isArray(keys) ? keys.map((k) => String(k || '').trim()).filter(Boolean) : [];
|
|
355
|
+
if (!list.length) {
|
|
356
|
+
const err = new Error('keys must be a non-empty array');
|
|
357
|
+
err.status = 400;
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const sourceEndpoint = await migrationAssets.resolveSourceEndpoint();
|
|
362
|
+
const targetEndpoint = await resolveTargetAssetEndpoint(targetEnvKey);
|
|
363
|
+
return migrationAssets.copyKeys({ keys: list, sourceEndpoint, targetEndpoint, dryRun: !!dryRun, batchSize });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getUploadDir() {
|
|
367
|
+
return process.env.UPLOAD_DIR || 'uploads';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buildFsPath(key) {
|
|
371
|
+
return path.join(process.cwd(), getUploadDir(), key);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function putObjectToTarget({ targetStorage, key, body, contentType }) {
|
|
375
|
+
if (targetStorage.backend === 'fs') {
|
|
376
|
+
const filePath = buildFsPath(key);
|
|
377
|
+
const dirPath = path.dirname(filePath);
|
|
378
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
379
|
+
fs.writeFileSync(filePath, body);
|
|
380
|
+
return { provider: 'fs', bucket: 'fs', key };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (targetStorage.backend === 's3') {
|
|
384
|
+
const cfg = targetStorage.s3;
|
|
385
|
+
if (!cfg) {
|
|
386
|
+
const err = new Error('Target S3 is not configured');
|
|
387
|
+
err.code = 'S3_NOT_CONFIGURED';
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
|
392
|
+
const client = new S3Client({
|
|
393
|
+
endpoint: cfg.endpoint,
|
|
394
|
+
region: cfg.region || 'us-east-1',
|
|
395
|
+
credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
|
|
396
|
+
forcePathStyle: Boolean(cfg.forcePathStyle),
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
401
|
+
} catch (e) {
|
|
402
|
+
const status = e?.$metadata?.httpStatusCode;
|
|
403
|
+
if (status === 404 || e?.name === 'NotFound') {
|
|
404
|
+
await client.send(new CreateBucketCommand({ Bucket: cfg.bucket }));
|
|
405
|
+
} else {
|
|
406
|
+
throw e;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await client.send(new PutObjectCommand({
|
|
411
|
+
Bucket: cfg.bucket,
|
|
412
|
+
Key: key,
|
|
413
|
+
Body: body,
|
|
414
|
+
ContentType: contentType || undefined,
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
return { provider: 's3', bucket: cfg.bucket, key };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const err = new Error('Unsupported target storage backend');
|
|
421
|
+
err.code = 'UNSUPPORTED_BACKEND';
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function copyAssetObjects({ assetDocs, targetEnvKey, batchSize = 20, dryRun = false } = {}) {
|
|
426
|
+
const docs = Array.isArray(assetDocs) ? assetDocs : [];
|
|
427
|
+
const keys = docs.map((d) => String(d?.key || '').trim()).filter(Boolean);
|
|
428
|
+
return copyAssetKeys({ targetEnvKey, keys, dryRun: !!dryRun, batchSize });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function getTargetConnection(envKey) {
|
|
432
|
+
const normalizedKey = normalizeEnvKey(envKey);
|
|
433
|
+
if (!normalizedKey) {
|
|
434
|
+
const err = new Error('envKey is required');
|
|
435
|
+
err.status = 400;
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const cached = connections.get(normalizedKey);
|
|
440
|
+
if (cached && cached.readyState === 1) return cached;
|
|
441
|
+
|
|
442
|
+
const cfg = await getEnvironmentConfig(normalizedKey);
|
|
443
|
+
if (!cfg?.connectionString) {
|
|
444
|
+
const err = new Error('Target environment not configured');
|
|
445
|
+
err.status = 400;
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const conn = mongoose.createConnection(cfg.connectionString, {
|
|
450
|
+
serverSelectionTimeoutMS: 8000,
|
|
451
|
+
maxPoolSize: 5,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await conn.asPromise();
|
|
455
|
+
connections.set(normalizedKey, conn);
|
|
456
|
+
return conn;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function testConnection(envKey) {
|
|
460
|
+
const conn = await getTargetConnection(envKey);
|
|
461
|
+
try {
|
|
462
|
+
await conn.db.admin().ping();
|
|
463
|
+
} catch (_) {
|
|
464
|
+
await conn.close().catch(() => {});
|
|
465
|
+
connections.delete(normalizeEnvKey(envKey));
|
|
466
|
+
throw _;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { ok: true };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function migrateModel({
|
|
473
|
+
sourceModel,
|
|
474
|
+
targetEnvKey,
|
|
475
|
+
query,
|
|
476
|
+
modelName,
|
|
477
|
+
batchSize = 200,
|
|
478
|
+
dryRun = false,
|
|
479
|
+
} = {}) {
|
|
480
|
+
if (!sourceModel || !sourceModel.schema) {
|
|
481
|
+
const err = new Error('sourceModel is required');
|
|
482
|
+
err.status = 400;
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const safeModelName = String(modelName || sourceModel.modelName || '').trim();
|
|
487
|
+
if (!safeModelName) {
|
|
488
|
+
const err = new Error('modelName is required');
|
|
489
|
+
err.status = 400;
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const targetConn = await getTargetConnection(targetEnvKey);
|
|
494
|
+
const TargetModel = targetConn.models[safeModelName]
|
|
495
|
+
|| targetConn.model(safeModelName, sourceModel.schema);
|
|
496
|
+
|
|
497
|
+
const filter = query && typeof query === 'object' ? query : {};
|
|
498
|
+
|
|
499
|
+
const total = await sourceModel.countDocuments(filter);
|
|
500
|
+
const result = {
|
|
501
|
+
ok: true,
|
|
502
|
+
modelName: safeModelName,
|
|
503
|
+
total,
|
|
504
|
+
processed: 0,
|
|
505
|
+
upserted: 0,
|
|
506
|
+
errors: [],
|
|
507
|
+
dryRun: !!dryRun,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const cursor = sourceModel.find(filter).lean().cursor();
|
|
511
|
+
|
|
512
|
+
let batch = [];
|
|
513
|
+
for await (const doc of cursor) {
|
|
514
|
+
batch.push(doc);
|
|
515
|
+
if (batch.length >= batchSize) {
|
|
516
|
+
const r = await flushBatch(TargetModel, batch, { dryRun });
|
|
517
|
+
result.processed += r.processed;
|
|
518
|
+
result.upserted += r.upserted;
|
|
519
|
+
result.errors.push(...r.errors);
|
|
520
|
+
batch = [];
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (batch.length) {
|
|
525
|
+
const r = await flushBatch(TargetModel, batch, { dryRun });
|
|
526
|
+
result.processed += r.processed;
|
|
527
|
+
result.upserted += r.upserted;
|
|
528
|
+
result.errors.push(...r.errors);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (result.errors.length) {
|
|
532
|
+
result.ok = false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function flushBatch(TargetModel, docs, { dryRun } = {}) {
|
|
539
|
+
const result = { processed: docs.length, upserted: 0, errors: [] };
|
|
540
|
+
if (dryRun) {
|
|
541
|
+
result.upserted = docs.length;
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const ops = docs.map((d) => {
|
|
546
|
+
const id = d._id;
|
|
547
|
+
const copy = { ...d };
|
|
548
|
+
delete copy.__v;
|
|
549
|
+
return {
|
|
550
|
+
replaceOne: {
|
|
551
|
+
filter: { _id: id },
|
|
552
|
+
replacement: copy,
|
|
553
|
+
upsert: true,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const res = await TargetModel.bulkWrite(ops, { ordered: false });
|
|
560
|
+
result.upserted = (res?.upsertedCount || 0) + (res?.modifiedCount || 0) + (res?.insertedCount || 0);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
result.errors.push({ error: e?.message ? String(e.message) : 'bulkWrite failed' });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return result;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
module.exports = {
|
|
569
|
+
ENV_PREFIX,
|
|
570
|
+
listEnvironments,
|
|
571
|
+
getEnvironmentConfig,
|
|
572
|
+
upsertEnvironment,
|
|
573
|
+
deleteEnvironment,
|
|
574
|
+
testConnection,
|
|
575
|
+
testAssetsTarget,
|
|
576
|
+
testAssetsCopyKey,
|
|
577
|
+
getTargetConnection,
|
|
578
|
+
migrateModel,
|
|
579
|
+
copyAssetKeys,
|
|
580
|
+
copyAssetObjects,
|
|
581
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function ensureDir(dirPath) {
|
|
5
|
+
if (!fs.existsSync(dirPath)) {
|
|
6
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveBaseDir(baseDir) {
|
|
11
|
+
const raw = String(baseDir || '').trim();
|
|
12
|
+
if (!raw) return path.join(process.cwd(), process.env.UPLOAD_DIR || 'uploads');
|
|
13
|
+
if (path.isAbsolute(raw)) return raw;
|
|
14
|
+
return path.join(process.cwd(), raw);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createFsLocalEndpoint({ baseDir } = {}) {
|
|
18
|
+
const baseDirAbs = resolveBaseDir(baseDir);
|
|
19
|
+
|
|
20
|
+
function buildPath(key) {
|
|
21
|
+
return path.join(baseDirAbs, key);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
type: 'fs_local',
|
|
26
|
+
baseDir: baseDirAbs,
|
|
27
|
+
|
|
28
|
+
async testWritable() {
|
|
29
|
+
ensureDir(baseDirAbs);
|
|
30
|
+
const testFile = path.join(baseDirAbs, `.__migration_test__${Date.now()}_${Math.random().toString(16).slice(2)}`);
|
|
31
|
+
fs.writeFileSync(testFile, Buffer.from('ok'));
|
|
32
|
+
fs.unlinkSync(testFile);
|
|
33
|
+
return { ok: true, baseDir: baseDirAbs };
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async getObject({ key }) {
|
|
37
|
+
const filePath = buildPath(key);
|
|
38
|
+
if (!fs.existsSync(filePath)) return null;
|
|
39
|
+
const body = fs.readFileSync(filePath);
|
|
40
|
+
return { body, contentType: null };
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async putObject({ key, body }) {
|
|
44
|
+
const p = buildPath(key);
|
|
45
|
+
await fs.promises.mkdir(path.dirname(p), { recursive: true });
|
|
46
|
+
await fs.promises.writeFile(p, body);
|
|
47
|
+
return { ok: true, key };
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
describeKey(key) {
|
|
51
|
+
return buildPath(key);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
createFsLocalEndpoint,
|
|
58
|
+
};
|