4bnode 4.0.9 → 4.1.1
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 +25 -206
- package/add-crud.js +6 -106
- package/add-docs.js +91 -0
- package/add-email.js +63 -0
- package/add-security.js +143 -0
- package/index.js +76 -8
- package/lib/codegen.js +1223 -0
- package/lib/codegen.test.js +480 -0
- package/lib/indexFile.js +10 -12
- package/lib/names.js +4 -21
- package/lib/routeFile.js +9 -27
- package/lib/sync-skeleton.js +47 -0
- package/lib/ui.js +15 -2
- package/package.json +4 -2
- package/scripts/audit-dev-surface.mjs +153 -0
- package/scripts/reveal-passkey.mjs +35 -0
- package/skeleton/.4bnode/assets/js/App.js +31 -9
- package/skeleton/.4bnode/assets/js/Root.js +1 -5
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
- package/skeleton/.4bnode/assets/js/lib.js +18 -5
- package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
- package/skeleton/.4bnode/codegen.js +1223 -0
- package/skeleton/.4bnode/dev-api.js +634 -728
- package/skeleton/.4bnode/ui.html +62 -40
- package/skeleton/_gitignore +7 -0
- package/skeleton/index.js +41 -3
- package/skeleton/package.json +4 -1
- package/skeleton/src/middleware/apiKey.js +36 -0
- package/skeleton/src/middleware/errorHandler.js +15 -0
- package/skeleton/src/middleware/roles.js +14 -0
- package/skeleton/src/middleware/validate.js +20 -0
- package/skeleton/.4bnode/assets/images/mysql.png +0 -0
- package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
- package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
|
@@ -6,9 +6,71 @@ import { promisify } from 'util';
|
|
|
6
6
|
import { pathToFileURL } from 'url';
|
|
7
7
|
const execAsync = promisify(exec);
|
|
8
8
|
import crypto from 'crypto';
|
|
9
|
+
// Shared, dependency-free code generation. This file is a verbatim copy of the
|
|
10
|
+
// 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen
|
|
11
|
+
// in the package, not here — it is regenerated when a project is scaffolded.
|
|
12
|
+
import {
|
|
13
|
+
toCamelCase,
|
|
14
|
+
toPascalCase,
|
|
15
|
+
sanitizeName,
|
|
16
|
+
addImportToContent,
|
|
17
|
+
insertCodeIntoContent,
|
|
18
|
+
buildInsertCode,
|
|
19
|
+
buildReadCode,
|
|
20
|
+
buildUpdateCode,
|
|
21
|
+
buildDeleteCode,
|
|
22
|
+
generateMongooseModel,
|
|
23
|
+
buildZodSchemaFile,
|
|
24
|
+
wireValidateIntoRouteContent,
|
|
25
|
+
addImportAfterLastImport,
|
|
26
|
+
insertBeforeListenContent,
|
|
27
|
+
AI_PLAN_SCHEMA,
|
|
28
|
+
AI_SYSTEM_PROMPT,
|
|
29
|
+
AI_PROVIDERS,
|
|
30
|
+
DEFAULT_AI_PROVIDER,
|
|
31
|
+
normalizeAiPlan,
|
|
32
|
+
buildCrudRouteFile,
|
|
33
|
+
addDepsToPackageJson,
|
|
34
|
+
extractRequestFields,
|
|
35
|
+
buildOpenApiSpec,
|
|
36
|
+
buildDocsRouter,
|
|
37
|
+
MAIL_PROVIDERS,
|
|
38
|
+
buildMailerService,
|
|
39
|
+
} from './codegen.js';
|
|
9
40
|
|
|
10
41
|
const router = express.Router();
|
|
11
42
|
|
|
43
|
+
// ── Network restriction ─────────────────────────────────────────────────────
|
|
44
|
+
// This router can write files and run shell commands — it is a remote-code-
|
|
45
|
+
// execution surface. Refuse any request that does not originate from the local
|
|
46
|
+
// machine, unless an operator has *explicitly* opted into remote access (in
|
|
47
|
+
// which case a passkey becomes mandatory, enforced by the auth middleware
|
|
48
|
+
// below). The check uses the raw TCP peer address — never X-Forwarded-* — so it
|
|
49
|
+
// cannot be spoofed by a request header. Behind a reverse proxy the peer is the
|
|
50
|
+
// proxy itself, so external traffic must also be blocked at the edge.
|
|
51
|
+
// Defense-in-depth production kill-switch. The dashboard should never be mounted
|
|
52
|
+
// in production (see index.js), but if it somehow is — a manual mount, a runtime
|
|
53
|
+
// NODE_ENV flip, a misconfigured host — refuse every request and behave as if the
|
|
54
|
+
// route does not exist (404, no information leak). Set _4BNODE_DASHBOARD=on to
|
|
55
|
+
// force-enable (loopback + passkey still apply).
|
|
56
|
+
const DASHBOARD_ENABLED =
|
|
57
|
+
process.env._4BNODE_DASHBOARD === 'on' ||
|
|
58
|
+
(process.env.NODE_ENV !== 'production' && process.env._4BNODE_DASHBOARD !== 'off');
|
|
59
|
+
router.use((req, res, next) => {
|
|
60
|
+
if (!DASHBOARD_ENABLED) return res.status(404).end();
|
|
61
|
+
next();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
|
|
65
|
+
function isLoopbackRequest(req) {
|
|
66
|
+
const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
|
|
67
|
+
return ip === '127.0.0.1' || ip === '::1';
|
|
68
|
+
}
|
|
69
|
+
router.use((req, res, next) => {
|
|
70
|
+
if (ALLOW_REMOTE || isLoopbackRequest(req)) return next();
|
|
71
|
+
return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
|
|
72
|
+
});
|
|
73
|
+
|
|
12
74
|
// ── Console Log Capture ─────────────────────────────────────────────────────
|
|
13
75
|
const _logBuffer = [];
|
|
14
76
|
const _logMaxSize = 500;
|
|
@@ -59,21 +121,86 @@ router.use('/assets', express.static(path.join(__4bnodeDir, 'assets')));
|
|
|
59
121
|
function getPasskeyHash() {
|
|
60
122
|
const file = path.join(__4bnodeDir, '.passkey');
|
|
61
123
|
if (!fs.existsSync(file)) return null;
|
|
62
|
-
return fs.readFileSync(file, 'utf8').trim();
|
|
124
|
+
return fs.readFileSync(file, 'utf8').trim() || null;
|
|
63
125
|
}
|
|
64
126
|
|
|
65
|
-
|
|
66
|
-
|
|
127
|
+
// Constant-time verification. Supports the current salted-scrypt format
|
|
128
|
+
// (`scrypt$<saltHex>$<hashHex>`) and the legacy unsalted sha256 hex digest so
|
|
129
|
+
// apps generated before the upgrade keep working.
|
|
130
|
+
function verifyPasskey(candidate, stored) {
|
|
131
|
+
if (!candidate || !stored) return false;
|
|
132
|
+
try {
|
|
133
|
+
if (stored.startsWith('scrypt$')) {
|
|
134
|
+
const [, saltHex, hashHex] = stored.split('$');
|
|
135
|
+
const salt = Buffer.from(saltHex, 'hex');
|
|
136
|
+
const expected = Buffer.from(hashHex, 'hex');
|
|
137
|
+
const actual = crypto.scryptSync(String(candidate), salt, expected.length);
|
|
138
|
+
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
|
139
|
+
}
|
|
140
|
+
const expected = Buffer.from(stored, 'hex');
|
|
141
|
+
const actual = crypto.createHash('sha256').update(String(candidate)).digest();
|
|
142
|
+
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Brute-force throttle. After AUTH_MAX_FAILS wrong guesses from one peer, lock it
|
|
149
|
+
// out for AUTH_LOCK_MS. In-memory (clears on restart) — enough to stop online
|
|
150
|
+
// guessing, which is the realistic attack once the dashboard is reachable.
|
|
151
|
+
const AUTH_MAX_FAILS = 5;
|
|
152
|
+
const AUTH_LOCK_MS = 60_000;
|
|
153
|
+
const _authFails = new Map(); // ip -> { fails, lockedUntil }
|
|
154
|
+
function authClientKey(req) {
|
|
155
|
+
return String((req.socket && req.socket.remoteAddress) || 'unknown').replace(/^::ffff:/, '');
|
|
156
|
+
}
|
|
157
|
+
function authLockState(req) {
|
|
158
|
+
const e = _authFails.get(authClientKey(req));
|
|
159
|
+
if (e && e.lockedUntil > Date.now()) {
|
|
160
|
+
return { locked: true, retryAfter: Math.ceil((e.lockedUntil - Date.now()) / 1000) };
|
|
161
|
+
}
|
|
162
|
+
return { locked: false, retryAfter: 0 };
|
|
163
|
+
}
|
|
164
|
+
function recordAuthFailure(req) {
|
|
165
|
+
const key = authClientKey(req);
|
|
166
|
+
const e = _authFails.get(key) || { fails: 0, lockedUntil: 0 };
|
|
167
|
+
e.fails += 1;
|
|
168
|
+
if (e.fails >= AUTH_MAX_FAILS) { e.lockedUntil = Date.now() + AUTH_LOCK_MS; e.fails = 0; }
|
|
169
|
+
_authFails.set(key, e);
|
|
170
|
+
}
|
|
171
|
+
function recordAuthSuccess(req) {
|
|
172
|
+
_authFails.delete(authClientKey(req));
|
|
67
173
|
}
|
|
68
174
|
|
|
69
175
|
// Verify passkey endpoint (no auth middleware needed here)
|
|
70
176
|
router.post('/api/auth', (req, res) => {
|
|
71
|
-
const
|
|
177
|
+
const lock = authLockState(req);
|
|
178
|
+
if (lock.locked) {
|
|
179
|
+
res.set('Retry-After', String(lock.retryAfter));
|
|
180
|
+
return res.status(429).json({ error: `Too many attempts. Try again in ${lock.retryAfter}s.` });
|
|
181
|
+
}
|
|
182
|
+
const { passkey } = req.body || {};
|
|
72
183
|
const stored = getPasskeyHash();
|
|
73
|
-
if (!stored)
|
|
74
|
-
|
|
184
|
+
if (!stored) {
|
|
185
|
+
// No passkey configured: permit only loopback dev. A missing passkey must
|
|
186
|
+
// never authenticate a remote caller (deny by default).
|
|
187
|
+
if (!isLoopbackRequest(req)) {
|
|
188
|
+
return res.status(401).json({ error: 'A dashboard passkey must be configured for remote access.' });
|
|
189
|
+
}
|
|
190
|
+
return res.json({ ok: true });
|
|
191
|
+
}
|
|
192
|
+
if (!verifyPasskey(passkey, stored)) {
|
|
193
|
+
// Only count a real guess (non-empty) so an unauthenticated page load that
|
|
194
|
+
// probes with an empty passkey doesn't burn the lockout budget.
|
|
195
|
+
if (passkey) recordAuthFailure(req);
|
|
196
|
+
const after = authLockState(req);
|
|
197
|
+
if (after.locked) {
|
|
198
|
+
res.set('Retry-After', String(after.retryAfter));
|
|
199
|
+
return res.status(429).json({ error: `Too many attempts. Try again in ${after.retryAfter}s.` });
|
|
200
|
+
}
|
|
75
201
|
return res.status(401).json({ error: 'Invalid passkey' });
|
|
76
202
|
}
|
|
203
|
+
recordAuthSuccess(req);
|
|
77
204
|
res.json({ ok: true });
|
|
78
205
|
});
|
|
79
206
|
|
|
@@ -83,40 +210,29 @@ router.use('/api', (req, res, next) => {
|
|
|
83
210
|
// SSE endpoint handles its own auth via query param (EventSource can't send headers)
|
|
84
211
|
if (req.path === '/logs/stream') return next();
|
|
85
212
|
const stored = getPasskeyHash();
|
|
86
|
-
if (!stored)
|
|
213
|
+
if (!stored) {
|
|
214
|
+
// Deny by default: a missing passkey may only pass for loopback dev, never
|
|
215
|
+
// for a remote caller (relevant when _4BNODE_ALLOW_REMOTE is enabled).
|
|
216
|
+
if (!isLoopbackRequest(req)) return res.status(401).json({ error: 'Unauthorized' });
|
|
217
|
+
return next();
|
|
218
|
+
}
|
|
219
|
+
const lock = authLockState(req);
|
|
220
|
+
if (lock.locked) {
|
|
221
|
+
res.set('Retry-After', String(lock.retryAfter));
|
|
222
|
+
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
223
|
+
}
|
|
87
224
|
const passkey = req.headers['x-passkey'];
|
|
88
|
-
if (!
|
|
225
|
+
if (!verifyPasskey(passkey, stored)) {
|
|
226
|
+
if (passkey) recordAuthFailure(req);
|
|
89
227
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
90
228
|
}
|
|
91
229
|
next();
|
|
92
230
|
});
|
|
93
231
|
|
|
94
232
|
// ---------------------------------------------------------------------------
|
|
95
|
-
// Utility functions (
|
|
233
|
+
// Utility functions (name helpers + code builders are imported from ./codegen.js)
|
|
96
234
|
// ---------------------------------------------------------------------------
|
|
97
235
|
|
|
98
|
-
function toCamelCase(str) {
|
|
99
|
-
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function toPascalCase(str) {
|
|
103
|
-
const camel = toCamelCase(str);
|
|
104
|
-
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function sanitizeName(input) {
|
|
108
|
-
if (!input || typeof input !== "string") {
|
|
109
|
-
throw new Error("Name is required.");
|
|
110
|
-
}
|
|
111
|
-
const trimmed = input.trim();
|
|
112
|
-
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
|
|
113
|
-
throw new Error(
|
|
114
|
-
`Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
return trimmed;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
236
|
function listModels() {
|
|
121
237
|
const modelsDir = path.join(process.cwd(), "src", "models");
|
|
122
238
|
if (!fs.existsSync(modelsDir)) {
|
|
@@ -167,6 +283,41 @@ function extractSchemaBlock(content) {
|
|
|
167
283
|
return null;
|
|
168
284
|
}
|
|
169
285
|
|
|
286
|
+
// The fields a route actually accepts in its body — used to generate a Zod schema
|
|
287
|
+
// scoped to THAT route (not the whole model, which many routes share with differing
|
|
288
|
+
// field subsets). Pulls types from the model where known; skips File fields (not in
|
|
289
|
+
// req.body). Returns null when the route takes no validatable body.
|
|
290
|
+
function routeValidatorFields(type, opts) {
|
|
291
|
+
const { findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName } = opts;
|
|
292
|
+
const modelFields = {};
|
|
293
|
+
try {
|
|
294
|
+
if (modelName) (getModelSchemaDetailed(sanitizeName(modelName)).fields || []).forEach((f) => { modelFields[f.name] = f; });
|
|
295
|
+
} catch {}
|
|
296
|
+
const make = (name, fallbackType, required) => {
|
|
297
|
+
const mf = modelFields[name];
|
|
298
|
+
return { name, type: (mf && mf.type) || fallbackType || 'String', required };
|
|
299
|
+
};
|
|
300
|
+
if (type === 'login') {
|
|
301
|
+
const fields = [make(findField, 'String', true)];
|
|
302
|
+
if (hasPassword) fields.push(make(passwordField, 'String', true));
|
|
303
|
+
return fields;
|
|
304
|
+
}
|
|
305
|
+
if (type === 'register') {
|
|
306
|
+
const regFields = (registerFields && registerFields.length) ? registerFields : [findField];
|
|
307
|
+
return regFields.map((f) => make(f, 'String', true));
|
|
308
|
+
}
|
|
309
|
+
if (type === 'crud' || type === 'methods') {
|
|
310
|
+
return (allFieldsCombined || [])
|
|
311
|
+
.filter((f) => f.type !== 'File')
|
|
312
|
+
.map((f) => {
|
|
313
|
+
const mf = modelFields[f.name];
|
|
314
|
+
const required = mf ? !!mf.required : f.required !== false;
|
|
315
|
+
return { name: f.name, type: (f.type && f.type !== 'File' && f.type) || (mf && mf.type) || 'String', required };
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
170
321
|
function getModelSchemaDetailed(modelName) {
|
|
171
322
|
const modelFilePath = path.join(
|
|
172
323
|
process.cwd(),
|
|
@@ -259,40 +410,21 @@ function getModelSchema(modelName) {
|
|
|
259
410
|
// Route file manipulation functions
|
|
260
411
|
// ---------------------------------------------------------------------------
|
|
261
412
|
|
|
413
|
+
// File-based wrappers around the shared pure string helpers in codegen.js.
|
|
262
414
|
function addImportToRoute(filePath, importStatement) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
415
|
+
const original = fs.readFileSync(filePath, "utf8");
|
|
416
|
+
const content = addImportToContent(original, importStatement);
|
|
417
|
+
if (content !== original) {
|
|
266
418
|
fs.writeFileSync(filePath, content, "utf8");
|
|
267
419
|
}
|
|
268
420
|
return content;
|
|
269
421
|
}
|
|
270
422
|
|
|
271
423
|
function insertCodeIntoRoute(filePath, codeBlock) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (exportIndex !== -1) {
|
|
277
|
-
content =
|
|
278
|
-
content.slice(0, exportIndex) +
|
|
279
|
-
codeBlock.trim() +
|
|
280
|
-
"\n\n" +
|
|
281
|
-
content.slice(exportIndex);
|
|
282
|
-
} else {
|
|
283
|
-
// Fallback to inserting before last "});"
|
|
284
|
-
const lastClose = content.lastIndexOf("});");
|
|
285
|
-
if (lastClose !== -1) {
|
|
286
|
-
content =
|
|
287
|
-
content.slice(0, lastClose) +
|
|
288
|
-
codeBlock.trim() +
|
|
289
|
-
"\n" +
|
|
290
|
-
content.slice(lastClose);
|
|
291
|
-
} else {
|
|
292
|
-
content += "\n" + codeBlock.trim() + "\n";
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
424
|
+
const content = insertCodeIntoContent(
|
|
425
|
+
fs.readFileSync(filePath, "utf8"),
|
|
426
|
+
codeBlock
|
|
427
|
+
);
|
|
296
428
|
fs.writeFileSync(filePath, content, "utf8");
|
|
297
429
|
}
|
|
298
430
|
|
|
@@ -308,154 +440,10 @@ function getRequestDataSource(method) {
|
|
|
308
440
|
return method === "get" || method === "delete" ? "req.query" : "req.body";
|
|
309
441
|
}
|
|
310
442
|
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
function buildInsertCode(pascalName, fields, dataSource) {
|
|
316
|
-
const assignments = fields
|
|
317
|
-
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
318
|
-
.join(",\n");
|
|
319
|
-
|
|
320
|
-
let passwordLogic = "";
|
|
321
|
-
if (fields.includes("password")) {
|
|
322
|
-
passwordLogic = `
|
|
323
|
-
if (newData.password) {
|
|
324
|
-
const salt = await bcrypt.genSalt(10);
|
|
325
|
-
newData.password = await bcrypt.hash(newData.password, salt);
|
|
326
|
-
}`;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return `
|
|
330
|
-
router.post('/create', async (req, res) => {
|
|
331
|
-
const newData = {
|
|
332
|
-
${assignments}
|
|
333
|
-
};
|
|
334
|
-
${passwordLogic}
|
|
335
|
-
try {
|
|
336
|
-
const newDocument = new ${pascalName}(newData);
|
|
337
|
-
await newDocument.save();
|
|
338
|
-
res.json({ message: 'Data inserted successfully', data: newDocument });
|
|
339
|
-
} catch (err) {
|
|
340
|
-
console.error(err);
|
|
341
|
-
res.status(500).json({ message: 'Server error' });
|
|
342
|
-
}
|
|
343
|
-
});`;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function buildReadCode(pascalName) {
|
|
347
|
-
return `
|
|
348
|
-
router.get('/', async (req, res) => {
|
|
349
|
-
try {
|
|
350
|
-
const documents = await ${pascalName}.find();
|
|
351
|
-
res.json({ data: documents });
|
|
352
|
-
} catch (err) {
|
|
353
|
-
console.error(err);
|
|
354
|
-
res.status(500).json({ message: 'Server error' });
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
router.get('/:id', async (req, res) => {
|
|
359
|
-
try {
|
|
360
|
-
const document = await ${pascalName}.findById(req.params.id);
|
|
361
|
-
if (!document) {
|
|
362
|
-
return res.status(404).json({ message: 'Document not found' });
|
|
363
|
-
}
|
|
364
|
-
res.json({ data: document });
|
|
365
|
-
} catch (err) {
|
|
366
|
-
console.error(err);
|
|
367
|
-
res.status(500).json({ message: 'Server error' });
|
|
368
|
-
}
|
|
369
|
-
});`;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function buildUpdateCode(pascalName, fields, dataSource) {
|
|
373
|
-
const assignments = fields
|
|
374
|
-
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
375
|
-
.join(",\n");
|
|
376
|
-
|
|
377
|
-
let passwordLogic = "";
|
|
378
|
-
if (fields.includes("password")) {
|
|
379
|
-
passwordLogic = `
|
|
380
|
-
if (updatedData.password) {
|
|
381
|
-
const salt = await bcrypt.genSalt(10);
|
|
382
|
-
updatedData.password = await bcrypt.hash(updatedData.password, salt);
|
|
383
|
-
}`;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return `
|
|
387
|
-
router.put('/:id', async (req, res) => {
|
|
388
|
-
const updatedData = {
|
|
389
|
-
${assignments}
|
|
390
|
-
};
|
|
391
|
-
${passwordLogic}
|
|
392
|
-
try {
|
|
393
|
-
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
|
|
394
|
-
if (!updatedDocument) {
|
|
395
|
-
return res.status(404).json({ message: 'Document not found' });
|
|
396
|
-
}
|
|
397
|
-
res.json({ message: 'Data updated successfully', data: updatedDocument });
|
|
398
|
-
} catch (err) {
|
|
399
|
-
console.error(err);
|
|
400
|
-
res.status(500).json({ message: 'Server error' });
|
|
401
|
-
}
|
|
402
|
-
});`;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function buildDeleteCode(pascalName) {
|
|
406
|
-
return `
|
|
407
|
-
router.delete('/:id', async (req, res) => {
|
|
408
|
-
try {
|
|
409
|
-
const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
|
|
410
|
-
if (!deletedDocument) {
|
|
411
|
-
return res.status(404).json({ message: 'Document not found' });
|
|
412
|
-
}
|
|
413
|
-
res.json({ message: 'Data deleted successfully', data: deletedDocument });
|
|
414
|
-
} catch (err) {
|
|
415
|
-
console.error(err);
|
|
416
|
-
res.status(500).json({ message: 'Server error' });
|
|
417
|
-
}
|
|
418
|
-
});`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// ---------------------------------------------------------------------------
|
|
422
|
-
// Schema file generation helper
|
|
423
|
-
// ---------------------------------------------------------------------------
|
|
424
|
-
|
|
425
|
-
function generateSchemaFileContent(pascalName, fields, indexes = []) {
|
|
426
|
-
const fieldLines = fields.map((f) => {
|
|
427
|
-
const type = f.type || 'String';
|
|
428
|
-
const parts = [type === 'ObjectId' ? 'type: mongoose.Schema.Types.ObjectId' : `type: ${type}`];
|
|
429
|
-
if (f.ref) parts.push(`ref: '${f.ref}'`);
|
|
430
|
-
if (f.required) parts.push('required: true');
|
|
431
|
-
if (f.unique) parts.push('unique: true');
|
|
432
|
-
if (f.index) parts.push('index: true');
|
|
433
|
-
if (f.default !== undefined && f.default !== '') parts.push(`default: ${f.default}`);
|
|
434
|
-
return ` ${f.name}: { ${parts.join(', ')} }`;
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
// Add index lines
|
|
438
|
-
let indexLines = '';
|
|
439
|
-
for (const idx of indexes) {
|
|
440
|
-
const fieldObj = idx.fields.map(f => {
|
|
441
|
-
if (f.direction === 'text') return `${f.field}: 'text'`;
|
|
442
|
-
return `${f.field}: ${f.direction || 1}`;
|
|
443
|
-
}).join(', ');
|
|
444
|
-
const options = idx.unique ? ', { unique: true }' : '';
|
|
445
|
-
indexLines += `\n${pascalName}Schema.index({ ${fieldObj} }${options});`;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return `import mongoose from 'mongoose';
|
|
449
|
-
|
|
450
|
-
const ${pascalName}Schema = new mongoose.Schema({
|
|
451
|
-
${fieldLines.join(',\n')}
|
|
452
|
-
}, { timestamps: true });
|
|
453
|
-
${indexLines}
|
|
454
|
-
const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
|
|
455
|
-
|
|
456
|
-
export default ${pascalName};
|
|
457
|
-
`;
|
|
458
|
-
}
|
|
443
|
+
// CRUD route builders and the Mongoose model generator are imported from
|
|
444
|
+
// ./codegen.js. `generateSchemaFileContent` is kept as a thin alias so existing
|
|
445
|
+
// call sites below remain unchanged.
|
|
446
|
+
const generateSchemaFileContent = generateMongooseModel;
|
|
459
447
|
|
|
460
448
|
// ---------------------------------------------------------------------------
|
|
461
449
|
// API Routes
|
|
@@ -610,6 +598,7 @@ router.post('/api/models', (req, res) => {
|
|
|
610
598
|
|
|
611
599
|
const content = generateSchemaFileContent(pascalName, fields, indexes);
|
|
612
600
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
601
|
+
refreshDocsSpec();
|
|
613
602
|
res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
|
|
614
603
|
} catch (err) {
|
|
615
604
|
res.status(500).json({ error: err.message });
|
|
@@ -630,6 +619,7 @@ router.put('/api/models/:name', (req, res) => {
|
|
|
630
619
|
|
|
631
620
|
const content = generateSchemaFileContent(pascalName, fields, indexes);
|
|
632
621
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
622
|
+
refreshDocsSpec();
|
|
633
623
|
res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
|
|
634
624
|
} catch (err) {
|
|
635
625
|
res.status(500).json({ error: err.message });
|
|
@@ -646,6 +636,7 @@ router.delete('/api/models/:name', (req, res) => {
|
|
|
646
636
|
}
|
|
647
637
|
|
|
648
638
|
fs.unlinkSync(filePath);
|
|
639
|
+
refreshDocsSpec();
|
|
649
640
|
res.json({ message: `Model "${name}" deleted.` });
|
|
650
641
|
} catch (err) {
|
|
651
642
|
res.status(500).json({ error: err.message });
|
|
@@ -692,6 +683,7 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
692
683
|
|
|
693
684
|
// Delete the route file
|
|
694
685
|
fs.unlinkSync(filePath);
|
|
686
|
+
refreshDocsSpec();
|
|
695
687
|
|
|
696
688
|
// Send response BEFORE modifying index.js (nodemon restart kills connection)
|
|
697
689
|
res.json({ message: `Route "${safeName}" deleted successfully.` });
|
|
@@ -711,27 +703,6 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
711
703
|
}
|
|
712
704
|
});
|
|
713
705
|
|
|
714
|
-
// PUT /api/routes/:name/content — edit route source code
|
|
715
|
-
// ---------------------------------------------------------------------------
|
|
716
|
-
router.put('/api/routes/:name/content', (req, res) => {
|
|
717
|
-
try {
|
|
718
|
-
const name = req.params.name;
|
|
719
|
-
const { content } = req.body;
|
|
720
|
-
if (!content || typeof content !== 'string') {
|
|
721
|
-
return res.status(400).json({ error: 'Content is required.' });
|
|
722
|
-
}
|
|
723
|
-
const routeFile = name.endsWith('.js') ? name : name + '.js';
|
|
724
|
-
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
725
|
-
if (!fs.existsSync(filePath)) {
|
|
726
|
-
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
727
|
-
}
|
|
728
|
-
fs.writeFileSync(filePath, content, 'utf8');
|
|
729
|
-
res.json({ message: `Route "${routeFile}" updated successfully.` });
|
|
730
|
-
} catch (err) {
|
|
731
|
-
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
732
|
-
}
|
|
733
|
-
});
|
|
734
|
-
|
|
735
706
|
// ---------------------------------------------------------------------------
|
|
736
707
|
// POST /api/routes/:name/add-method — add handlers to existing route
|
|
737
708
|
// ---------------------------------------------------------------------------
|
|
@@ -921,11 +892,12 @@ router.post('/api/routes/:name/add-method', async (req, res) => {
|
|
|
921
892
|
|
|
922
893
|
// Install packages
|
|
923
894
|
if (packagesToInstall.length > 0) {
|
|
924
|
-
|
|
895
|
+
await installDeps([...new Set(packagesToInstall)]);
|
|
925
896
|
}
|
|
926
897
|
|
|
927
898
|
insertHandler(handlers);
|
|
928
899
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
900
|
+
refreshDocsSpec();
|
|
929
901
|
|
|
930
902
|
const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase();
|
|
931
903
|
res.json({ message: `${label} added to "${name}" route.`, content });
|
|
@@ -934,91 +906,6 @@ router.post('/api/routes/:name/add-method', async (req, res) => {
|
|
|
934
906
|
}
|
|
935
907
|
});
|
|
936
908
|
|
|
937
|
-
// GET /api/routes/:name/share — generate shareable endpoint docs with field metadata
|
|
938
|
-
router.get('/api/routes/:name/share', (req, res) => {
|
|
939
|
-
try {
|
|
940
|
-
const name = req.params.name.replace(/\.js$/, '');
|
|
941
|
-
const routeFile = name + '.js';
|
|
942
|
-
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
943
|
-
if (!fs.existsSync(filePath)) {
|
|
944
|
-
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
945
|
-
}
|
|
946
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
947
|
-
|
|
948
|
-
// Determine the route prefix from index.js
|
|
949
|
-
const indexPath = path.join(process.cwd(), 'index.js');
|
|
950
|
-
let prefix = '/api/' + name;
|
|
951
|
-
if (fs.existsSync(indexPath)) {
|
|
952
|
-
const indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
953
|
-
const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
|
|
954
|
-
if (prefixMatch) prefix = prefixMatch[1];
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Check if route imports a model
|
|
958
|
-
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
959
|
-
let modelFields = [];
|
|
960
|
-
if (modelImportMatch) {
|
|
961
|
-
modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// Parse each endpoint with body/query fields
|
|
965
|
-
const endpoints = [];
|
|
966
|
-
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*(?:auth\s*,\s*)?(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>\s*\{([\s\S]*?)(?=\nrouter\.|$)/gi;
|
|
967
|
-
let m;
|
|
968
|
-
while ((m = epRegex.exec(content)) !== null) {
|
|
969
|
-
const method = m[1].toUpperCase();
|
|
970
|
-
const epath = m[2];
|
|
971
|
-
const body = m[3] || '';
|
|
972
|
-
|
|
973
|
-
const bodyFields = [];
|
|
974
|
-
const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
|
|
975
|
-
if (bodyMatch) {
|
|
976
|
-
bodyMatch[1].split(',').forEach(f => {
|
|
977
|
-
const fname = f.trim();
|
|
978
|
-
if (fname) {
|
|
979
|
-
const meta = modelFields.find(mf => mf.name === fname);
|
|
980
|
-
bodyFields.push({
|
|
981
|
-
name: fname,
|
|
982
|
-
type: meta?.type || 'String',
|
|
983
|
-
required: meta?.required || false,
|
|
984
|
-
unique: meta?.unique || false,
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
const queryFields = [];
|
|
991
|
-
const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
|
|
992
|
-
if (queryMatch) {
|
|
993
|
-
queryMatch[1].split(',').forEach(f => {
|
|
994
|
-
const fname = f.trim();
|
|
995
|
-
if (fname) queryFields.push({ name: fname, type: 'String' });
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Check for params
|
|
1000
|
-
const params = [];
|
|
1001
|
-
const paramMatches = epath.match(/:(\w+)/g);
|
|
1002
|
-
if (paramMatches) {
|
|
1003
|
-
paramMatches.forEach(p => params.push(p.replace(':', '')));
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
endpoints.push({ method, path: epath, fullPath: prefix + (epath === '/' ? '' : epath), bodyFields, queryFields, params });
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
res.json({
|
|
1010
|
-
name,
|
|
1011
|
-
prefix,
|
|
1012
|
-
routeFile,
|
|
1013
|
-
modelName: modelImportMatch ? modelImportMatch[2] : null,
|
|
1014
|
-
modelFields,
|
|
1015
|
-
endpoints,
|
|
1016
|
-
});
|
|
1017
|
-
} catch (err) {
|
|
1018
|
-
res.status(500).json({ error: err.message });
|
|
1019
|
-
}
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
909
|
// ---------------------------------------------------------------------------
|
|
1023
910
|
// POST /api/crud/preview
|
|
1024
911
|
router.post('/api/crud/preview', (req, res) => {
|
|
@@ -1161,30 +1048,13 @@ function writeIndexFile(content) {
|
|
|
1161
1048
|
fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8');
|
|
1162
1049
|
}
|
|
1163
1050
|
|
|
1051
|
+
// File-based wrappers around the shared pure index.js transforms in codegen.js.
|
|
1164
1052
|
function addImportToIndex(importLine) {
|
|
1165
|
-
|
|
1166
|
-
if (content.includes(importLine.trim())) return;
|
|
1167
|
-
const lines = content.split('\n');
|
|
1168
|
-
let lastImportIdx = -1;
|
|
1169
|
-
lines.forEach((line, i) => {
|
|
1170
|
-
if (line.startsWith('import ')) lastImportIdx = i;
|
|
1171
|
-
});
|
|
1172
|
-
lines.splice(lastImportIdx + 1, 0, importLine.trim());
|
|
1173
|
-
writeIndexFile(lines.join('\n'));
|
|
1053
|
+
writeIndexFile(addImportAfterLastImport(readIndexFile(), importLine));
|
|
1174
1054
|
}
|
|
1175
1055
|
|
|
1176
1056
|
function insertBeforeListen(codeBlock) {
|
|
1177
|
-
|
|
1178
|
-
const listenRegex = /(app|server)\.listen\(port/m;
|
|
1179
|
-
let match = content.match(listenRegex);
|
|
1180
|
-
if (!match) {
|
|
1181
|
-
// Match the startServer() call, not the function declaration
|
|
1182
|
-
match = content.match(/^startServer\(/m);
|
|
1183
|
-
}
|
|
1184
|
-
if (match) {
|
|
1185
|
-
content = content.slice(0, match.index) + codeBlock.trim() + '\n' + content.slice(match.index);
|
|
1186
|
-
}
|
|
1187
|
-
writeIndexFile(content);
|
|
1057
|
+
writeIndexFile(insertBeforeListenContent(readIndexFile(), codeBlock));
|
|
1188
1058
|
}
|
|
1189
1059
|
|
|
1190
1060
|
function replaceAppListen(newBlock) {
|
|
@@ -1273,6 +1143,26 @@ function updateEnvFile(filePath, key, value) {
|
|
|
1273
1143
|
}
|
|
1274
1144
|
}
|
|
1275
1145
|
|
|
1146
|
+
// Record packages in package.json so the app stays installable even if the live
|
|
1147
|
+
// `npm install` below is offline/interrupted. Always call this BEFORE npm install.
|
|
1148
|
+
function recordDeps(names, dev = false) {
|
|
1149
|
+
try {
|
|
1150
|
+
const pkgPath = path.join(process.cwd(), 'package.json');
|
|
1151
|
+
if (!fs.existsSync(pkgPath)) return;
|
|
1152
|
+
const list = Array.isArray(names) ? names : [names];
|
|
1153
|
+
const { content, changed } = addDepsToPackageJson(fs.readFileSync(pkgPath, 'utf8'), list, { dev });
|
|
1154
|
+
if (changed) fs.writeFileSync(pkgPath, content, 'utf8');
|
|
1155
|
+
} catch {}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Record deps then run npm install (best-effort). Replaces bare execAsync('npm install …').
|
|
1159
|
+
async function installDeps(names, { dev = false } = {}) {
|
|
1160
|
+
const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
|
|
1161
|
+
if (list.length === 0) return;
|
|
1162
|
+
recordDeps(list, dev);
|
|
1163
|
+
try { await execAsync(`npm install ${dev ? '-D ' : ''}${list.join(' ')}`, { cwd: process.cwd() }); } catch {}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1276
1166
|
// ---------------------------------------------------------------------------
|
|
1277
1167
|
// GET /api/status
|
|
1278
1168
|
// ---------------------------------------------------------------------------
|
|
@@ -1288,19 +1178,392 @@ router.get('/api/status', (req, res) => {
|
|
|
1288
1178
|
const websocketConfigured = indexContent.includes('setupWebSocket') || indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
|
|
1289
1179
|
const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
|
|
1290
1180
|
|
|
1181
|
+
const mwExists = (f) => fs.existsSync(path.join(process.cwd(), 'src', 'middleware', f));
|
|
1182
|
+
const security = {
|
|
1183
|
+
helmet: indexContent.includes("from 'helmet'") || indexContent.includes('app.use(helmet'),
|
|
1184
|
+
rateLimit: indexContent.includes("from 'express-rate-limit'") || indexContent.includes('rateLimit('),
|
|
1185
|
+
errorHandler: mwExists('errorHandler.js'),
|
|
1186
|
+
roles: mwExists('roles.js'),
|
|
1187
|
+
apiKey: mwExists('apiKey.js'),
|
|
1188
|
+
validation: mwExists('validate.js'),
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
// Folder name, sanitized into a valid default database name (Mongo db names
|
|
1192
|
+
// can't contain / \ . " $ * < > : | ? or spaces).
|
|
1193
|
+
const appName = (path.basename(process.cwd()) || 'app')
|
|
1194
|
+
.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase() || 'app';
|
|
1195
|
+
|
|
1291
1196
|
res.json({
|
|
1197
|
+
appName,
|
|
1292
1198
|
mongoConfigured,
|
|
1293
1199
|
routesDirExists,
|
|
1294
1200
|
authMiddlewareExists,
|
|
1295
1201
|
socketConfigured,
|
|
1296
1202
|
websocketConfigured,
|
|
1297
|
-
serialportConfigured
|
|
1203
|
+
serialportConfigured,
|
|
1204
|
+
security,
|
|
1205
|
+
aiConfigured: !!getActiveAiKey(),
|
|
1206
|
+
jobsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'jobs', 'scheduler.js')),
|
|
1207
|
+
docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
|
|
1208
|
+
deployConfigured: fs.existsSync(path.join(process.cwd(), 'Dockerfile')),
|
|
1209
|
+
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js')),
|
|
1210
|
+
testsConfigured: fs.existsSync(path.join(process.cwd(), 'vitest.config.js'))
|
|
1298
1211
|
});
|
|
1299
1212
|
} catch (err) {
|
|
1300
1213
|
res.status(500).json({ error: err.message });
|
|
1301
1214
|
}
|
|
1302
1215
|
});
|
|
1303
1216
|
|
|
1217
|
+
// ---------------------------------------------------------------------------
|
|
1218
|
+
// API Key management — named keys + metadata in src/api-keys.json. This file
|
|
1219
|
+
// ships with the app (unlike .4bnode), and the apiKey middleware reads it live,
|
|
1220
|
+
// so generate/revoke take effect without a restart. Writing a .json file is not
|
|
1221
|
+
// watched by nodemon, so no restart is triggered here either.
|
|
1222
|
+
// ---------------------------------------------------------------------------
|
|
1223
|
+
function apiKeysFilePath() {
|
|
1224
|
+
return path.join(process.cwd(), 'src', 'api-keys.json');
|
|
1225
|
+
}
|
|
1226
|
+
function readApiKeys() {
|
|
1227
|
+
try {
|
|
1228
|
+
const p = apiKeysFilePath();
|
|
1229
|
+
if (fs.existsSync(p)) {
|
|
1230
|
+
const list = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1231
|
+
if (Array.isArray(list)) return list;
|
|
1232
|
+
}
|
|
1233
|
+
} catch {}
|
|
1234
|
+
return [];
|
|
1235
|
+
}
|
|
1236
|
+
function writeApiKeys(list) {
|
|
1237
|
+
const p = apiKeysFilePath();
|
|
1238
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1239
|
+
fs.writeFileSync(p, JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
1240
|
+
}
|
|
1241
|
+
function genApiKey(label) {
|
|
1242
|
+
return {
|
|
1243
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
1244
|
+
label: (String(label || '').trim() || 'Untitled').slice(0, 60),
|
|
1245
|
+
key: 'sk_' + crypto.randomBytes(24).toString('hex'),
|
|
1246
|
+
createdAt: new Date().toISOString(),
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// GET/POST rate-limit config — stored in .env (read by index.js at startup).
|
|
1251
|
+
router.get('/api/security/rate-limit', (req, res) => {
|
|
1252
|
+
try {
|
|
1253
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1254
|
+
const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
|
|
1255
|
+
const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 100;
|
|
1256
|
+
res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
res.status(500).json({ error: err.message });
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
router.post('/api/security/rate-limit', (req, res) => {
|
|
1262
|
+
try {
|
|
1263
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1264
|
+
const windowMinutes = Math.max(1, Math.min(1440, Number(req.body && req.body.windowMinutes) || 15));
|
|
1265
|
+
const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 100));
|
|
1266
|
+
updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
|
|
1267
|
+
updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
|
|
1268
|
+
res.json({ message: 'Rate limit updated.', windowMinutes, max });
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
res.status(500).json({ error: err.message });
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
// GET — list keys + whether the apiKey middleware is configured
|
|
1275
|
+
router.get('/api/security/api-keys', (req, res) => {
|
|
1276
|
+
try {
|
|
1277
|
+
const configured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'apiKey.js'));
|
|
1278
|
+
res.json({ configured, keys: readApiKeys() });
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
res.status(500).json({ error: err.message });
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// POST — generate a new named key
|
|
1285
|
+
router.post('/api/security/api-keys', (req, res) => {
|
|
1286
|
+
try {
|
|
1287
|
+
const list = readApiKeys();
|
|
1288
|
+
const key = genApiKey(req.body && req.body.label);
|
|
1289
|
+
list.push(key);
|
|
1290
|
+
writeApiKeys(list);
|
|
1291
|
+
res.json({ message: 'API key generated.', key });
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
res.status(500).json({ error: err.message });
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
// PATCH — rename a key
|
|
1298
|
+
router.patch('/api/security/api-keys/:id', (req, res) => {
|
|
1299
|
+
try {
|
|
1300
|
+
const list = readApiKeys();
|
|
1301
|
+
const key = list.find((k) => k.id === req.params.id);
|
|
1302
|
+
if (!key) return res.status(404).json({ error: 'Key not found.' });
|
|
1303
|
+
key.label = (String((req.body && req.body.label) || '').trim() || key.label).slice(0, 60);
|
|
1304
|
+
writeApiKeys(list);
|
|
1305
|
+
res.json({ message: 'API key renamed.', key });
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
res.status(500).json({ error: err.message });
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// DELETE — revoke a key
|
|
1312
|
+
router.delete('/api/security/api-keys/:id', (req, res) => {
|
|
1313
|
+
try {
|
|
1314
|
+
const before = readApiKeys();
|
|
1315
|
+
const after = before.filter((k) => k.id !== req.params.id);
|
|
1316
|
+
if (after.length === before.length) return res.status(404).json({ error: 'Key not found.' });
|
|
1317
|
+
writeApiKeys(after);
|
|
1318
|
+
res.json({ message: 'API key revoked.' });
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
res.status(500).json({ error: err.message });
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
// AI Builder — describe a backend in English, generate models + CRUD routes
|
|
1326
|
+
// ---------------------------------------------------------------------------
|
|
1327
|
+
// Currently selected provider (AI_PROVIDER in .env), validated against AI_PROVIDERS.
|
|
1328
|
+
function getAiProvider() {
|
|
1329
|
+
const fromFile = getEnvValue(path.join(process.cwd(), '.env'), 'AI_PROVIDER');
|
|
1330
|
+
const id = (fromFile && fromFile.trim()) || (process.env.AI_PROVIDER && process.env.AI_PROVIDER.trim()) || DEFAULT_AI_PROVIDER;
|
|
1331
|
+
return AI_PROVIDERS[id] ? id : DEFAULT_AI_PROVIDER;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function getProviderKey(providerId) {
|
|
1335
|
+
const provider = AI_PROVIDERS[providerId];
|
|
1336
|
+
if (!provider) return '';
|
|
1337
|
+
const fromEnv = process.env[provider.keyEnv];
|
|
1338
|
+
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
|
1339
|
+
return getEnvValue(path.join(process.cwd(), '.env'), provider.keyEnv);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Active provider's API key (used by the plan endpoint).
|
|
1343
|
+
function getActiveAiKey() {
|
|
1344
|
+
return getProviderKey(getAiProvider());
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Effective model: explicit AI_MODEL override, else the active provider's default.
|
|
1348
|
+
function getAiModel(providerId) {
|
|
1349
|
+
const fromFile = getEnvValue(path.join(process.cwd(), '.env'), 'AI_MODEL');
|
|
1350
|
+
const override = (fromFile && fromFile.trim()) || (process.env.AI_MODEL && process.env.AI_MODEL.trim());
|
|
1351
|
+
return override || AI_PROVIDERS[providerId].defaultModel;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Fields for a route's model — from the plan if present, else from an existing model on disk.
|
|
1355
|
+
function fieldsForModel(modelName, plan) {
|
|
1356
|
+
const inPlan = (plan.models || []).find((m) => m.name === modelName);
|
|
1357
|
+
if (inPlan) return inPlan.fields.map((f) => f.name);
|
|
1358
|
+
return getModelSchema(modelName);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// ── OpenAI: chat completions with a forced function call ──
|
|
1362
|
+
async function callOpenAIPlan(prompt, model, apiKey) {
|
|
1363
|
+
const resp = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
1364
|
+
method: 'POST',
|
|
1365
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
1366
|
+
body: JSON.stringify({
|
|
1367
|
+
model,
|
|
1368
|
+
messages: [
|
|
1369
|
+
{ role: 'system', content: AI_SYSTEM_PROMPT },
|
|
1370
|
+
{ role: 'user', content: prompt },
|
|
1371
|
+
],
|
|
1372
|
+
tools: [{ type: 'function', function: { name: 'emit_plan', description: 'Emit the backend plan to generate.', parameters: AI_PLAN_SCHEMA } }],
|
|
1373
|
+
tool_choice: { type: 'function', function: { name: 'emit_plan' } },
|
|
1374
|
+
}),
|
|
1375
|
+
});
|
|
1376
|
+
let data;
|
|
1377
|
+
try { data = await resp.json(); } catch { data = null; }
|
|
1378
|
+
if (!resp.ok) {
|
|
1379
|
+
const e = new Error((data && data.error && data.error.message) || `OpenAI API error (${resp.status})`);
|
|
1380
|
+
e.status = resp.status === 401 ? 401 : 502;
|
|
1381
|
+
throw e;
|
|
1382
|
+
}
|
|
1383
|
+
const toolCall = data?.choices?.[0]?.message?.tool_calls?.[0];
|
|
1384
|
+
if (!toolCall?.function?.arguments) throw new Error('The model did not return a structured plan. Try rephrasing.');
|
|
1385
|
+
try { return JSON.parse(toolCall.function.arguments); }
|
|
1386
|
+
catch { throw new Error('The model returned an unparseable plan. Try again.'); }
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// ── Anthropic: messages with a forced tool call ──
|
|
1390
|
+
async function callAnthropicPlan(prompt, model, apiKey) {
|
|
1391
|
+
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
1392
|
+
method: 'POST',
|
|
1393
|
+
headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
1394
|
+
body: JSON.stringify({
|
|
1395
|
+
model,
|
|
1396
|
+
max_tokens: 4096,
|
|
1397
|
+
system: AI_SYSTEM_PROMPT,
|
|
1398
|
+
tools: [{ name: 'emit_plan', description: 'Emit the backend plan to generate.', input_schema: AI_PLAN_SCHEMA }],
|
|
1399
|
+
tool_choice: { type: 'tool', name: 'emit_plan' },
|
|
1400
|
+
messages: [{ role: 'user', content: prompt }],
|
|
1401
|
+
}),
|
|
1402
|
+
});
|
|
1403
|
+
let data;
|
|
1404
|
+
try { data = await resp.json(); } catch { data = null; }
|
|
1405
|
+
if (!resp.ok) {
|
|
1406
|
+
const e = new Error((data && data.error && data.error.message) || `Anthropic API error (${resp.status})`);
|
|
1407
|
+
e.status = resp.status === 401 ? 401 : 502;
|
|
1408
|
+
throw e;
|
|
1409
|
+
}
|
|
1410
|
+
const toolUse = Array.isArray(data.content) ? data.content.find((c) => c.type === 'tool_use') : null;
|
|
1411
|
+
if (!toolUse) throw new Error('The model did not return a structured plan. Try rephrasing.');
|
|
1412
|
+
return toolUse.input;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Dispatch a plan request to the active provider.
|
|
1416
|
+
async function callAiPlan(prompt) {
|
|
1417
|
+
const providerId = getAiProvider();
|
|
1418
|
+
const apiKey = getProviderKey(providerId);
|
|
1419
|
+
if (!apiKey) {
|
|
1420
|
+
const e = new Error(`No ${AI_PROVIDERS[providerId].label} API key configured. Add it in AI Builder settings.`);
|
|
1421
|
+
e.status = 400;
|
|
1422
|
+
throw e;
|
|
1423
|
+
}
|
|
1424
|
+
const model = getAiModel(providerId);
|
|
1425
|
+
return providerId === 'anthropic'
|
|
1426
|
+
? callAnthropicPlan(prompt, model, apiKey)
|
|
1427
|
+
: callOpenAIPlan(prompt, model, apiKey);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// GET /api/ai/config — active provider, effective model, and per-provider status
|
|
1431
|
+
router.get('/api/ai/config', (req, res) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const provider = getAiProvider();
|
|
1434
|
+
const providers = Object.values(AI_PROVIDERS).map((p) => ({
|
|
1435
|
+
id: p.id,
|
|
1436
|
+
label: p.label,
|
|
1437
|
+
keyPlaceholder: p.keyPlaceholder,
|
|
1438
|
+
keysUrl: p.keysUrl,
|
|
1439
|
+
defaultModel: p.defaultModel,
|
|
1440
|
+
configured: !!getProviderKey(p.id),
|
|
1441
|
+
}));
|
|
1442
|
+
res.json({ provider, model: getAiModel(provider), providers });
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
res.status(500).json({ error: err.message });
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// POST /api/ai/key — save a provider's API key and make it the active provider
|
|
1449
|
+
router.post('/api/ai/key', (req, res) => {
|
|
1450
|
+
try {
|
|
1451
|
+
const providerId = (req.body && req.body.provider ? String(req.body.provider) : '').trim();
|
|
1452
|
+
const key = (req.body && req.body.key ? String(req.body.key) : '').trim();
|
|
1453
|
+
const provider = AI_PROVIDERS[providerId];
|
|
1454
|
+
if (!provider) return res.status(400).json({ error: 'Unknown provider.' });
|
|
1455
|
+
if (!key) return res.status(400).json({ error: 'API key is required.' });
|
|
1456
|
+
|
|
1457
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1458
|
+
updateEnvFile(envPath, provider.keyEnv, key);
|
|
1459
|
+
updateEnvFile(envPath, 'AI_PROVIDER', providerId);
|
|
1460
|
+
process.env[provider.keyEnv] = key; // usable immediately, no restart
|
|
1461
|
+
process.env.AI_PROVIDER = providerId;
|
|
1462
|
+
res.json({ message: `${provider.label} key saved and selected.`, provider: providerId });
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
res.status(500).json({ error: err.message });
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
// POST /api/ai/provider — switch the active provider (key must already exist)
|
|
1469
|
+
router.post('/api/ai/provider', (req, res) => {
|
|
1470
|
+
try {
|
|
1471
|
+
const providerId = (req.body && req.body.provider ? String(req.body.provider) : '').trim();
|
|
1472
|
+
const provider = AI_PROVIDERS[providerId];
|
|
1473
|
+
if (!provider) return res.status(400).json({ error: 'Unknown provider.' });
|
|
1474
|
+
if (!getProviderKey(providerId)) {
|
|
1475
|
+
return res.status(400).json({ error: `Add a ${provider.label} key first.` });
|
|
1476
|
+
}
|
|
1477
|
+
updateEnvFile(path.join(process.cwd(), '.env'), 'AI_PROVIDER', providerId);
|
|
1478
|
+
process.env.AI_PROVIDER = providerId;
|
|
1479
|
+
res.json({ message: `Switched to ${provider.label}.`, provider: providerId });
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
res.status(500).json({ error: err.message });
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// POST /api/ai/plan — turn a prompt into a normalized plan + code previews
|
|
1486
|
+
router.post('/api/ai/plan', async (req, res) => {
|
|
1487
|
+
try {
|
|
1488
|
+
const prompt = (req.body && req.body.prompt ? String(req.body.prompt) : '').trim();
|
|
1489
|
+
if (!prompt) return res.status(400).json({ error: 'Describe what you want to build.' });
|
|
1490
|
+
|
|
1491
|
+
const raw = await callAiPlan(prompt);
|
|
1492
|
+
const plan = normalizeAiPlan(raw);
|
|
1493
|
+
if (plan.models.length === 0 && plan.routes.length === 0) {
|
|
1494
|
+
return res.status(422).json({ error: 'Could not derive a buildable plan. Try being more specific.' });
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const preview = {
|
|
1498
|
+
summary: plan.summary,
|
|
1499
|
+
models: plan.models.map((m) => ({
|
|
1500
|
+
name: m.name,
|
|
1501
|
+
fields: m.fields,
|
|
1502
|
+
code: generateMongooseModel(toPascalCase(m.name), m.fields),
|
|
1503
|
+
})),
|
|
1504
|
+
routes: plan.routes.map((r) => ({
|
|
1505
|
+
name: r.name,
|
|
1506
|
+
model: r.model,
|
|
1507
|
+
operations: r.operations,
|
|
1508
|
+
code: buildCrudRouteFile(r.name, r.model, fieldsForModel(r.model, plan), r.operations),
|
|
1509
|
+
})),
|
|
1510
|
+
};
|
|
1511
|
+
res.json({ plan, preview });
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
res.status(err.status || 500).json({ error: err.message });
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
// POST /api/ai/apply — generate the model + route files from an approved plan
|
|
1518
|
+
router.post('/api/ai/apply', async (req, res) => {
|
|
1519
|
+
try {
|
|
1520
|
+
const plan = normalizeAiPlan(req.body && req.body.plan ? req.body.plan : {});
|
|
1521
|
+
if (plan.models.length === 0 && plan.routes.length === 0) {
|
|
1522
|
+
return res.status(400).json({ error: 'Empty plan.' });
|
|
1523
|
+
}
|
|
1524
|
+
if (!checkMongoConfig()) {
|
|
1525
|
+
return res.status(400).json({ error: 'Configure MongoDB before generating models.' });
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const created = { models: [], routes: [], skipped: [] };
|
|
1529
|
+
|
|
1530
|
+
const modelsDir = path.join(process.cwd(), 'src', 'models');
|
|
1531
|
+
fs.mkdirSync(modelsDir, { recursive: true });
|
|
1532
|
+
for (const m of plan.models) {
|
|
1533
|
+
const file = path.join(modelsDir, m.name + '.js');
|
|
1534
|
+
if (fs.existsSync(file)) { created.skipped.push('model:' + m.name); continue; }
|
|
1535
|
+
fs.writeFileSync(file, generateMongooseModel(toPascalCase(m.name), m.fields), 'utf8');
|
|
1536
|
+
created.models.push(m.name);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const routesDir = path.join(process.cwd(), 'src', 'routes');
|
|
1540
|
+
fs.mkdirSync(routesDir, { recursive: true });
|
|
1541
|
+
for (const r of plan.routes) {
|
|
1542
|
+
const file = path.join(routesDir, r.name + '.js');
|
|
1543
|
+
if (fs.existsSync(file)) { created.skipped.push('route:' + r.name); continue; }
|
|
1544
|
+
const fields = fieldsForModel(r.model, plan);
|
|
1545
|
+
fs.writeFileSync(file, buildCrudRouteFile(r.name, r.model, fields, r.operations), 'utf8');
|
|
1546
|
+
created.routes.push(r.name);
|
|
1547
|
+
const camel = toCamelCase(r.name);
|
|
1548
|
+
addImportToIndex(`import ${camel}Router from './src/routes/${r.name}.js';`);
|
|
1549
|
+
const reg = `app.use('/api/${r.name}', ${camel}Router);`;
|
|
1550
|
+
if (!readIndexFile().includes(reg)) insertBeforeListen(reg);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const anyPassword = plan.routes.some((r) => fieldsForModel(r.model, plan).includes('password'));
|
|
1554
|
+
if (anyPassword) {
|
|
1555
|
+
await installDeps(['bcrypt']);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
res.json({
|
|
1559
|
+
message: `Generated ${created.models.length} model(s) and ${created.routes.length} route(s).`,
|
|
1560
|
+
...created,
|
|
1561
|
+
});
|
|
1562
|
+
} catch (err) {
|
|
1563
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1304
1567
|
// ---------------------------------------------------------------------------
|
|
1305
1568
|
// POST /api/add-api
|
|
1306
1569
|
// ---------------------------------------------------------------------------
|
|
@@ -1334,6 +1597,7 @@ router.post('/api/add-api', async (req, res) => {
|
|
|
1334
1597
|
const needsMulter = allFieldsCombined.some(f => f.type === 'File');
|
|
1335
1598
|
if (needsMulter) packagesToInstall.push('multer');
|
|
1336
1599
|
if (packagesToInstall.length > 0) {
|
|
1600
|
+
recordDeps(packagesToInstall);
|
|
1337
1601
|
await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
|
|
1338
1602
|
}
|
|
1339
1603
|
|
|
@@ -1645,12 +1909,27 @@ router.post('/', async (req, res) => {
|
|
|
1645
1909
|
}
|
|
1646
1910
|
}
|
|
1647
1911
|
|
|
1648
|
-
|
|
1912
|
+
let content = `${imports}
|
|
1649
1913
|
const router = express.Router();
|
|
1650
1914
|
${handlers}
|
|
1651
1915
|
export default router;
|
|
1652
1916
|
`;
|
|
1653
1917
|
|
|
1918
|
+
// Per-route validation: generate a Zod schema from the fields THIS route
|
|
1919
|
+
// accepts (not the whole model — one model is used by many routes, each with a
|
|
1920
|
+
// different subset of fields). Named after the route (e.g. login → loginSchema),
|
|
1921
|
+
// it's wired into the route's write endpoints. File fields are skipped (they're
|
|
1922
|
+
// not in req.body). Types come from the model where known.
|
|
1923
|
+
const validatorFields = routeValidatorFields(type, {
|
|
1924
|
+
findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName,
|
|
1925
|
+
});
|
|
1926
|
+
if (validatorFields && validatorFields.length) {
|
|
1927
|
+
const validatorsDir = path.join(process.cwd(), 'src', 'validators');
|
|
1928
|
+
fs.mkdirSync(validatorsDir, { recursive: true });
|
|
1929
|
+
fs.writeFileSync(path.join(validatorsDir, safeName + '.js'), buildZodSchemaFile(safeName, validatorFields), 'utf8');
|
|
1930
|
+
content = wireValidateIntoRouteContent(content, safeName);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1654
1933
|
fs.writeFileSync(routeFilePath, content, 'utf8');
|
|
1655
1934
|
|
|
1656
1935
|
const label = type === 'methods' ? methodList.map(m => m.toUpperCase()).join(', ') : type === 'crud' ? 'CRUD (' + (methodList.length > 0 ? methodList.map(m => m.toUpperCase()).join(', ') : 'ALL') + ')' : type === 'plain' ? 'PLAIN' : type.toUpperCase();
|
|
@@ -1683,6 +1962,7 @@ export default router;
|
|
|
1683
1962
|
if (!indexContent.includes(routeRegistration)) {
|
|
1684
1963
|
insertBeforeListen(routeRegistration);
|
|
1685
1964
|
}
|
|
1965
|
+
refreshDocsSpec();
|
|
1686
1966
|
} catch (err) {
|
|
1687
1967
|
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1688
1968
|
}
|
|
@@ -1712,6 +1992,7 @@ router.post('/api/add-mongo', async (req, res) => {
|
|
|
1712
1992
|
}
|
|
1713
1993
|
|
|
1714
1994
|
// First-time setup: install mongoose, create db.js, modify index.js
|
|
1995
|
+
recordDeps(['mongoose']);
|
|
1715
1996
|
await execAsync('npm install mongoose', { cwd: process.cwd() });
|
|
1716
1997
|
|
|
1717
1998
|
const dbDir = path.join(process.cwd(), 'src');
|
|
@@ -1768,6 +2049,7 @@ router.post('/api/add-login', async (req, res) => {
|
|
|
1768
2049
|
|
|
1769
2050
|
// Install packages FIRST before modifying files
|
|
1770
2051
|
const packages = hasPassword ? 'jsonwebtoken bcrypt' : 'jsonwebtoken';
|
|
2052
|
+
recordDeps(String(packages).split(/\s+/).filter(Boolean));
|
|
1771
2053
|
await execAsync(`npm install ${packages}`, { cwd: process.cwd() });
|
|
1772
2054
|
|
|
1773
2055
|
// Create src/middleware/auth.js if it doesn't exist
|
|
@@ -1919,6 +2201,7 @@ router.post('/api/add-socket', async (req, res) => {
|
|
|
1919
2201
|
const currentContent = readIndexFile();
|
|
1920
2202
|
const hasWebSocket = currentContent.includes('setupWebSocket') || currentContent.includes('WebSocketServer');
|
|
1921
2203
|
|
|
2204
|
+
recordDeps(['socket.io']);
|
|
1922
2205
|
if (hasWebSocket) {
|
|
1923
2206
|
try { await execAsync('npm uninstall ws && npm install socket.io', { cwd: process.cwd() }); } catch {
|
|
1924
2207
|
try { await execAsync('npm install socket.io', { cwd: process.cwd() }); } catch {}
|
|
@@ -2035,6 +2318,7 @@ router.post('/api/add-websocket', async (req, res) => {
|
|
|
2035
2318
|
const currentContent = readIndexFile();
|
|
2036
2319
|
const hasSocketIO = currentContent.includes('setupSocketIO') || currentContent.includes('socket.io');
|
|
2037
2320
|
|
|
2321
|
+
recordDeps(['ws']);
|
|
2038
2322
|
if (hasSocketIO) {
|
|
2039
2323
|
try { await execAsync('npm uninstall socket.io && npm install ws', { cwd: process.cwd() }); } catch {
|
|
2040
2324
|
try { await execAsync('npm install ws', { cwd: process.cwd() }); } catch {}
|
|
@@ -2186,6 +2470,7 @@ router.post('/api/add-serialport', async (req, res) => {
|
|
|
2186
2470
|
// Install FIRST before modifying index.js
|
|
2187
2471
|
const indexContent = readIndexFile();
|
|
2188
2472
|
if (!indexContent.includes("from 'serialport'")) {
|
|
2473
|
+
recordDeps(['serialport', '@serialport/parser-readline']);
|
|
2189
2474
|
await execAsync('npm install serialport @serialport/parser-readline', { cwd: process.cwd() });
|
|
2190
2475
|
}
|
|
2191
2476
|
|
|
@@ -2450,23 +2735,13 @@ router.get('/api/api-tester/routes', (req, res) => {
|
|
|
2450
2735
|
const bodyFields = [];
|
|
2451
2736
|
const queryFields = [];
|
|
2452
2737
|
const fileFields = [];
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
}
|
|
2461
|
-
});
|
|
2462
|
-
}
|
|
2463
|
-
const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
|
|
2464
|
-
if (queryMatch) {
|
|
2465
|
-
queryMatch[1].split(',').forEach(f => {
|
|
2466
|
-
const fname = f.trim();
|
|
2467
|
-
if (fname) queryFields.push({ name: fname, type: 'String' });
|
|
2468
|
-
});
|
|
2469
|
-
}
|
|
2738
|
+
extractRequestFields(body, 'body').forEach(fname => {
|
|
2739
|
+
const meta = modelFields.find(mf => mf.name === fname);
|
|
2740
|
+
bodyFields.push({ name: fname, type: meta?.type || 'String', required: meta?.required || false, unique: meta?.unique || false });
|
|
2741
|
+
});
|
|
2742
|
+
extractRequestFields(body, 'query').forEach(fname => {
|
|
2743
|
+
queryFields.push({ name: fname, type: 'String' });
|
|
2744
|
+
});
|
|
2470
2745
|
// Detect file fields from multer middleware and req.file/req.files
|
|
2471
2746
|
const uploadSingleMatch = body.match(/upload\.single\(\s*['"](\w+)['"]\s*\)/);
|
|
2472
2747
|
if (uploadSingleMatch) fileFields.push({ name: uploadSingleMatch[1] });
|
|
@@ -2513,381 +2788,6 @@ router.get('/api/api-tester/routes', (req, res) => {
|
|
|
2513
2788
|
}
|
|
2514
2789
|
});
|
|
2515
2790
|
|
|
2516
|
-
// ---------------------------------------------------------------------------
|
|
2517
|
-
// Reports — schema info & Excel generation
|
|
2518
|
-
// ---------------------------------------------------------------------------
|
|
2519
|
-
router.get('/api/reports/schema-info', (req, res) => {
|
|
2520
|
-
try {
|
|
2521
|
-
const modelFiles = listModels();
|
|
2522
|
-
const models = modelFiles.map(f => {
|
|
2523
|
-
const name = path.basename(f, '.js');
|
|
2524
|
-
const detail = getModelSchemaDetailed(name);
|
|
2525
|
-
const fields = Array.isArray(detail) ? detail : detail.fields;
|
|
2526
|
-
return { name, fields: fields.map(field => {
|
|
2527
|
-
const result = { ...field };
|
|
2528
|
-
if (field.type === 'ObjectId' && field.ref) {
|
|
2529
|
-
const refDetail = getModelSchemaDetailed(field.ref);
|
|
2530
|
-
const refFields = Array.isArray(refDetail) ? refDetail : refDetail.fields;
|
|
2531
|
-
result.refFields = refFields.map(rf => rf.name);
|
|
2532
|
-
}
|
|
2533
|
-
return result;
|
|
2534
|
-
})};
|
|
2535
|
-
});
|
|
2536
|
-
res.json({ models });
|
|
2537
|
-
} catch (err) {
|
|
2538
|
-
res.status(500).json({ error: err.message });
|
|
2539
|
-
}
|
|
2540
|
-
});
|
|
2541
|
-
|
|
2542
|
-
// Date formatting helper
|
|
2543
|
-
function formatDateValue(val, format, tz) {
|
|
2544
|
-
if (!(val instanceof Date)) {
|
|
2545
|
-
try { val = new Date(val); } catch { return String(val); }
|
|
2546
|
-
if (isNaN(val.getTime())) return String(val);
|
|
2547
|
-
}
|
|
2548
|
-
if (format === 'ISO') return val.toISOString();
|
|
2549
|
-
if (format === 'UNIX') return Math.floor(val.getTime() / 1000);
|
|
2550
|
-
const isAmPm = format.includes(' A');
|
|
2551
|
-
const opts = { timeZone: tz || 'UTC', hour12: isAmPm };
|
|
2552
|
-
const parts = {};
|
|
2553
|
-
try {
|
|
2554
|
-
const fmt = new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
2555
|
-
fmt.formatToParts(val).forEach(p => { parts[p.type] = p.value; });
|
|
2556
|
-
} catch { return val.toISOString(); }
|
|
2557
|
-
const Y = parts.year, M = parts.month, D = parts.day;
|
|
2558
|
-
const H = parts.hour, m = parts.minute, s = parts.second;
|
|
2559
|
-
const ampm = parts.dayPeriod || '';
|
|
2560
|
-
const time = isAmPm ? `${H}:${m}:${s} ${ampm}` : `${H}:${m}:${s}`;
|
|
2561
|
-
// Normalize hour for 24h (en-US with hour12:false gives "24" for midnight, fix to "00")
|
|
2562
|
-
const H24 = !isAmPm && H === '24' ? '00' : H;
|
|
2563
|
-
const time24 = `${H24}:${m}:${s}`;
|
|
2564
|
-
switch (format) {
|
|
2565
|
-
case 'YYYY-MM-DD HH:mm:ss': return `${Y}-${M}-${D} ${time24}`;
|
|
2566
|
-
case 'DD/MM/YYYY HH:mm:ss': return `${D}/${M}/${Y} ${time24}`;
|
|
2567
|
-
case 'MM/DD/YYYY HH:mm:ss': return `${M}/${D}/${Y} ${time24}`;
|
|
2568
|
-
case 'YYYY-MM-DD hh:mm:ss A': return `${Y}-${M}-${D} ${time}`;
|
|
2569
|
-
case 'DD/MM/YYYY hh:mm:ss A': return `${D}/${M}/${Y} ${time}`;
|
|
2570
|
-
case 'MM/DD/YYYY hh:mm:ss A': return `${M}/${D}/${Y} ${time}`;
|
|
2571
|
-
case 'YYYY-MM-DD': return `${Y}-${M}-${D}`;
|
|
2572
|
-
case 'DD/MM/YYYY': return `${D}/${M}/${Y}`;
|
|
2573
|
-
case 'MM/DD/YYYY': return `${M}/${D}/${Y}`;
|
|
2574
|
-
default: return `${Y}-${M}-${D} ${time24}`;
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
function formatVal(val, dateFormat, timezone) {
|
|
2579
|
-
if (val === null || val === undefined) return null;
|
|
2580
|
-
// Native Date
|
|
2581
|
-
if (val instanceof Date) return formatDateValue(val, dateFormat, timezone);
|
|
2582
|
-
// String that looks like ISO date
|
|
2583
|
-
if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(val)) return formatDateValue(val, dateFormat, timezone);
|
|
2584
|
-
// Non-null objects (BSON types from MongoDB driver)
|
|
2585
|
-
if (typeof val === 'object') {
|
|
2586
|
-
const str = val.toString();
|
|
2587
|
-
// BSON Date — toString gives a date string, or has getTime
|
|
2588
|
-
if (typeof val.getTime === 'function') return formatDateValue(new Date(val.getTime()), dateFormat, timezone);
|
|
2589
|
-
// ObjectId or other BSON — toString gives hex string
|
|
2590
|
-
if (str && str !== '[object Object]') return str;
|
|
2591
|
-
return JSON.stringify(val);
|
|
2592
|
-
}
|
|
2593
|
-
return val;
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
router.post('/api/reports/preview', async (req, res) => {
|
|
2597
|
-
try {
|
|
2598
|
-
const { name, fields = [], systemFields = [], populatedFields = {}, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC', fieldPrefixes = {} } = req.body;
|
|
2599
|
-
if (!name) return res.status(400).json({ error: 'Model name required' });
|
|
2600
|
-
|
|
2601
|
-
const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
|
|
2602
|
-
if (!fs.existsSync(projectMongoose)) return res.status(400).json({ error: 'Mongoose not installed.' });
|
|
2603
|
-
const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
|
|
2604
|
-
if (mongoose.connection.readyState === 0) {
|
|
2605
|
-
const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
|
|
2606
|
-
if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
|
|
2607
|
-
await mongoose.connect(uri);
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
const collection = mongoose.connection.db.collection(name + 's');
|
|
2611
|
-
const rawDocs = await collection.find({}).limit(10).toArray();
|
|
2612
|
-
// Convert BSON to plain JSON objects for reliable field access
|
|
2613
|
-
const docs = JSON.parse(JSON.stringify(rawDocs));
|
|
2614
|
-
|
|
2615
|
-
// Populate referenced fields
|
|
2616
|
-
const lookups = {};
|
|
2617
|
-
for (const [refField, subFields] of Object.entries(populatedFields)) {
|
|
2618
|
-
if (!subFields || subFields.length === 0) continue;
|
|
2619
|
-
const detail = getModelSchemaDetailed(name);
|
|
2620
|
-
const schemaFields = detail.fields || [];
|
|
2621
|
-
const fieldDef = schemaFields.find(f => f.name === refField);
|
|
2622
|
-
if (!fieldDef || !fieldDef.ref) continue;
|
|
2623
|
-
const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id.toString()) : String(id)))];
|
|
2624
|
-
if (ids.length === 0) continue;
|
|
2625
|
-
const { ObjectId } = mongoose.Types;
|
|
2626
|
-
const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
|
|
2627
|
-
.find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
|
|
2628
|
-
const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
|
|
2629
|
-
const map = {};
|
|
2630
|
-
for (const rd of refDocs) {
|
|
2631
|
-
const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
|
|
2632
|
-
map[String(rdId)] = rd;
|
|
2633
|
-
}
|
|
2634
|
-
lookups[refField] = { subFields, map };
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
function applyPrefix(key, val) {
|
|
2638
|
-
if (val === null || val === undefined) return val;
|
|
2639
|
-
const prefix = fieldPrefixes[key];
|
|
2640
|
-
return prefix ? prefix + val : val;
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
const rows = docs.map(doc => {
|
|
2644
|
-
const row = {};
|
|
2645
|
-
// System fields first
|
|
2646
|
-
for (const sf of systemFields) {
|
|
2647
|
-
let val = doc[sf];
|
|
2648
|
-
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2649
|
-
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2650
|
-
row[sf] = applyPrefix(sf, formatVal(val, dateFormat, timezone));
|
|
2651
|
-
}
|
|
2652
|
-
// Model fields
|
|
2653
|
-
for (const f of fields) {
|
|
2654
|
-
let val = doc[f];
|
|
2655
|
-
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2656
|
-
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2657
|
-
row[f] = applyPrefix(f, formatVal(val, dateFormat, timezone));
|
|
2658
|
-
}
|
|
2659
|
-
// Populated ref fields
|
|
2660
|
-
for (const [refField, { subFields, map }] of Object.entries(lookups)) {
|
|
2661
|
-
let refId = doc[refField];
|
|
2662
|
-
if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
|
|
2663
|
-
const refDoc = refId ? map[String(refId)] : null;
|
|
2664
|
-
for (const sf of subFields) {
|
|
2665
|
-
const colKey = refField + '.' + sf;
|
|
2666
|
-
let val = refDoc ? refDoc[sf] : null;
|
|
2667
|
-
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2668
|
-
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2669
|
-
row[colKey] = applyPrefix(colKey, formatVal(val, dateFormat, timezone));
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
return row;
|
|
2673
|
-
});
|
|
2674
|
-
|
|
2675
|
-
// Reorder columns if columnOrder provided
|
|
2676
|
-
const columnOrder = req.body.columnOrder;
|
|
2677
|
-
if (columnOrder && columnOrder.length > 0) {
|
|
2678
|
-
const ordered = rows.map(row => {
|
|
2679
|
-
const r = {};
|
|
2680
|
-
columnOrder.forEach(col => { if (col in row) r[col] = row[col]; });
|
|
2681
|
-
return r;
|
|
2682
|
-
});
|
|
2683
|
-
return res.json({ rows: ordered });
|
|
2684
|
-
}
|
|
2685
|
-
res.json({ rows });
|
|
2686
|
-
} catch (err) {
|
|
2687
|
-
res.status(500).json({ error: err.message });
|
|
2688
|
-
}
|
|
2689
|
-
});
|
|
2690
|
-
|
|
2691
|
-
router.post('/api/reports/generate', async (req, res) => {
|
|
2692
|
-
try {
|
|
2693
|
-
const { models: reportModels, filename, dateFormat = 'YYYY-MM-DD HH:mm:ss', timezone = 'UTC' } = req.body;
|
|
2694
|
-
if (!reportModels || !reportModels.length) {
|
|
2695
|
-
return res.status(400).json({ error: 'No models selected' });
|
|
2696
|
-
}
|
|
2697
|
-
|
|
2698
|
-
// Try to import xlsx-js-style (supports styling) or fall back to xlsx
|
|
2699
|
-
let XLSX;
|
|
2700
|
-
try {
|
|
2701
|
-
const styledPkg = path.join(process.cwd(), 'node_modules', 'xlsx-js-style');
|
|
2702
|
-
const plainPkg = path.join(process.cwd(), 'node_modules', 'xlsx');
|
|
2703
|
-
if (!fs.existsSync(styledPkg)) {
|
|
2704
|
-
await execAsync('npm install xlsx-js-style', { cwd: process.cwd() });
|
|
2705
|
-
}
|
|
2706
|
-
// Use createRequire for CJS packages
|
|
2707
|
-
const { createRequire } = await import('module');
|
|
2708
|
-
const require = createRequire(process.cwd() + '/');
|
|
2709
|
-
XLSX = fs.existsSync(styledPkg) ? require('xlsx-js-style') : require('xlsx');
|
|
2710
|
-
} catch (e) {
|
|
2711
|
-
return res.status(400).json({ error: 'Failed to load xlsx. Run: npm install xlsx-js-style' });
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
// Connect to MongoDB
|
|
2715
|
-
const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
|
|
2716
|
-
if (!fs.existsSync(projectMongoose)) {
|
|
2717
|
-
return res.status(400).json({ error: 'Mongoose is not installed.' });
|
|
2718
|
-
}
|
|
2719
|
-
const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
|
|
2720
|
-
if (mongoose.connection.readyState === 0) {
|
|
2721
|
-
const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
|
|
2722
|
-
if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
|
|
2723
|
-
await mongoose.connect(uri);
|
|
2724
|
-
}
|
|
2725
|
-
|
|
2726
|
-
const wb = XLSX.utils.book_new();
|
|
2727
|
-
|
|
2728
|
-
for (const rm of reportModels) {
|
|
2729
|
-
const collectionName = rm.name + 's';
|
|
2730
|
-
const collection = mongoose.connection.db.collection(collectionName);
|
|
2731
|
-
const rowLimit = req.body.limit > 0 ? Math.min(req.body.limit, 50000) : 10000;
|
|
2732
|
-
const rawDocs = await collection.find({}).limit(rowLimit).toArray();
|
|
2733
|
-
const docs = JSON.parse(JSON.stringify(rawDocs));
|
|
2734
|
-
|
|
2735
|
-
// Build lookup maps for populated fields
|
|
2736
|
-
const lookups = {};
|
|
2737
|
-
if (rm.populatedFields) {
|
|
2738
|
-
for (const [refField, subFields] of Object.entries(rm.populatedFields)) {
|
|
2739
|
-
if (!subFields || subFields.length === 0) continue;
|
|
2740
|
-
const detail = getModelSchemaDetailed(rm.name);
|
|
2741
|
-
const schemaFields = detail.fields || [];
|
|
2742
|
-
const fieldDef = schemaFields.find(f => f.name === refField);
|
|
2743
|
-
if (!fieldDef || !fieldDef.ref) continue;
|
|
2744
|
-
|
|
2745
|
-
const ids = [...new Set(docs.map(d => d[refField]).filter(Boolean).map(id => typeof id === 'object' ? (id.$oid || id) : String(id)))];
|
|
2746
|
-
if (ids.length === 0) continue;
|
|
2747
|
-
|
|
2748
|
-
const { ObjectId } = mongoose.Types;
|
|
2749
|
-
const rawRefDocs = await mongoose.connection.db.collection(fieldDef.ref + 's')
|
|
2750
|
-
.find({ _id: { $in: ids.map(id => { try { return new ObjectId(id); } catch { return id; } }) } }).toArray();
|
|
2751
|
-
const refDocs = JSON.parse(JSON.stringify(rawRefDocs));
|
|
2752
|
-
|
|
2753
|
-
const map = {};
|
|
2754
|
-
for (const rd of refDocs) {
|
|
2755
|
-
const rdId = typeof rd._id === 'object' ? (rd._id.$oid || rd._id) : rd._id;
|
|
2756
|
-
map[String(rdId)] = rd;
|
|
2757
|
-
}
|
|
2758
|
-
lookups[refField] = { subFields, map };
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
// Build rows
|
|
2763
|
-
const sysFields = rm.systemFields || [];
|
|
2764
|
-
const rmPrefixes = rm.fieldPrefixes || {};
|
|
2765
|
-
function applyRmPrefix(key, val) {
|
|
2766
|
-
if (val === null || val === undefined || val === '') return val;
|
|
2767
|
-
const prefix = rmPrefixes[key];
|
|
2768
|
-
return prefix ? prefix + val : val;
|
|
2769
|
-
}
|
|
2770
|
-
const rows = docs.map(doc => {
|
|
2771
|
-
const row = {};
|
|
2772
|
-
// System fields first
|
|
2773
|
-
for (const sf of sysFields) {
|
|
2774
|
-
let val = doc[sf];
|
|
2775
|
-
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2776
|
-
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2777
|
-
const fv = formatVal(val, dateFormat, timezone);
|
|
2778
|
-
row[sf] = applyRmPrefix(sf, fv === null ? '' : fv);
|
|
2779
|
-
}
|
|
2780
|
-
// Model fields
|
|
2781
|
-
for (const field of rm.fields) {
|
|
2782
|
-
let val = doc[field];
|
|
2783
|
-
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2784
|
-
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2785
|
-
const fv = formatVal(val, dateFormat, timezone);
|
|
2786
|
-
row[field] = applyRmPrefix(field, fv === null ? '' : fv);
|
|
2787
|
-
}
|
|
2788
|
-
// Populated ref fields
|
|
2789
|
-
for (const [refField, { subFields, map }] of Object.entries(lookups)) {
|
|
2790
|
-
let refId = doc[refField];
|
|
2791
|
-
if (refId && typeof refId === 'object' && refId.$oid) refId = refId.$oid;
|
|
2792
|
-
const refDoc = refId ? map[String(refId)] : null;
|
|
2793
|
-
for (const sf of subFields) {
|
|
2794
|
-
const colKey = refField + '.' + sf;
|
|
2795
|
-
let val = refDoc ? refDoc[sf] : null;
|
|
2796
|
-
if (val && typeof val === 'object' && val.$oid) val = val.$oid;
|
|
2797
|
-
if (val && typeof val === 'object' && val.$date) val = val.$date;
|
|
2798
|
-
const fv = formatVal(val, dateFormat, timezone);
|
|
2799
|
-
row[colKey] = applyRmPrefix(colKey, fv === null ? '' : fv);
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
return row;
|
|
2803
|
-
});
|
|
2804
|
-
|
|
2805
|
-
// Reorder columns if columnOrder provided
|
|
2806
|
-
const colOrder = rm.columnOrder;
|
|
2807
|
-
const headerLabels = rm.headerLabels || {};
|
|
2808
|
-
let finalRows = rows;
|
|
2809
|
-
if (colOrder && colOrder.length > 0) {
|
|
2810
|
-
finalRows = rows.map(row => {
|
|
2811
|
-
const r = {};
|
|
2812
|
-
colOrder.forEach(col => {
|
|
2813
|
-
const label = headerLabels[col] || col;
|
|
2814
|
-
if (col in row) r[label] = row[col];
|
|
2815
|
-
});
|
|
2816
|
-
return r;
|
|
2817
|
-
});
|
|
2818
|
-
} else if (Object.keys(headerLabels).length > 0) {
|
|
2819
|
-
finalRows = rows.map(row => {
|
|
2820
|
-
const r = {};
|
|
2821
|
-
Object.keys(row).forEach(col => {
|
|
2822
|
-
const label = headerLabels[col] || col;
|
|
2823
|
-
r[label] = row[col];
|
|
2824
|
-
});
|
|
2825
|
-
return r;
|
|
2826
|
-
});
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
// Build headers with custom labels
|
|
2830
|
-
const rawHeaders = colOrder && colOrder.length > 0 ? colOrder : Object.keys(rows[0] || {});
|
|
2831
|
-
const displayHeaders = rawHeaders.map(h => headerLabels[h] || h);
|
|
2832
|
-
|
|
2833
|
-
// Create worksheet with explicit column order
|
|
2834
|
-
const ws = XLSX.utils.json_to_sheet(finalRows, { header: displayHeaders });
|
|
2835
|
-
|
|
2836
|
-
// Professional styling
|
|
2837
|
-
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1');
|
|
2838
|
-
// Style header row — bold with background
|
|
2839
|
-
const thinBorder = { style: "thin", color: { rgb: "B4C6E7" } };
|
|
2840
|
-
const allBorders = { top: thinBorder, bottom: thinBorder, left: thinBorder, right: thinBorder };
|
|
2841
|
-
const headerBorder = { top: { style: "thin", color: { rgb: "2F5597" } }, bottom: { style: "thin", color: { rgb: "2F5597" } }, left: { style: "thin", color: { rgb: "2F5597" } }, right: { style: "thin", color: { rgb: "2F5597" } } };
|
|
2842
|
-
// Style header row
|
|
2843
|
-
for (let C = range.s.c; C <= range.e.c; C++) {
|
|
2844
|
-
const addr = XLSX.utils.encode_cell({ r: 0, c: C });
|
|
2845
|
-
if (!ws[addr]) continue;
|
|
2846
|
-
ws[addr].s = {
|
|
2847
|
-
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 12, name: "Calibri" },
|
|
2848
|
-
fill: { fgColor: { rgb: "4472C4" } },
|
|
2849
|
-
alignment: { horizontal: "center", vertical: "center" },
|
|
2850
|
-
border: headerBorder,
|
|
2851
|
-
};
|
|
2852
|
-
}
|
|
2853
|
-
// Alternate row colors with full borders
|
|
2854
|
-
for (let R = 1; R <= range.e.r; R++) {
|
|
2855
|
-
const isEven = R % 2 === 0;
|
|
2856
|
-
for (let C = range.s.c; C <= range.e.c; C++) {
|
|
2857
|
-
const addr = XLSX.utils.encode_cell({ r: R, c: C });
|
|
2858
|
-
if (!ws[addr]) ws[addr] = { v: '', t: 's' };
|
|
2859
|
-
ws[addr].s = {
|
|
2860
|
-
fill: isEven ? { fgColor: { rgb: "D6E4F0" } } : { fgColor: { rgb: "FFFFFF" } },
|
|
2861
|
-
font: { sz: 11, color: { rgb: "333333" }, name: "Calibri" },
|
|
2862
|
-
alignment: { vertical: "center" },
|
|
2863
|
-
border: allBorders,
|
|
2864
|
-
};
|
|
2865
|
-
}
|
|
2866
|
-
}
|
|
2867
|
-
// Column widths with breathing room
|
|
2868
|
-
ws['!cols'] = displayHeaders.map((key, i) => {
|
|
2869
|
-
const rawKey = rawHeaders[i];
|
|
2870
|
-
const maxLen = Math.max(key.length, ...finalRows.map(r => String(r[key] || '').length).slice(0, 100));
|
|
2871
|
-
return { wch: maxLen + 4 };
|
|
2872
|
-
});
|
|
2873
|
-
// Row heights — uniform for all rows
|
|
2874
|
-
const totalRows = range.e.r + 1;
|
|
2875
|
-
ws['!rows'] = Array.from({ length: totalRows }, () => ({ hpt: 26 }));
|
|
2876
|
-
|
|
2877
|
-
const sheetName = rm.name.length > 31 ? rm.name.slice(0, 31) : rm.name;
|
|
2878
|
-
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
|
2879
|
-
}
|
|
2880
|
-
|
|
2881
|
-
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
|
2882
|
-
const fname = (filename || 'report') + '.xlsx';
|
|
2883
|
-
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
2884
|
-
res.setHeader('Content-Disposition', 'attachment; filename="' + fname + '"');
|
|
2885
|
-
res.send(Buffer.from(buf));
|
|
2886
|
-
} catch (err) {
|
|
2887
|
-
res.status(500).json({ error: err.message });
|
|
2888
|
-
}
|
|
2889
|
-
});
|
|
2890
|
-
|
|
2891
2791
|
// ---------------------------------------------------------------------------
|
|
2892
2792
|
// Logs — history, SSE stream, clear
|
|
2893
2793
|
// ---------------------------------------------------------------------------
|
|
@@ -2899,8 +2799,14 @@ router.get('/api/logs/stream', (req, res) => {
|
|
|
2899
2799
|
// EventSource can't send custom headers, so verify passkey from query param
|
|
2900
2800
|
const stored = getPasskeyHash();
|
|
2901
2801
|
if (stored) {
|
|
2802
|
+
const lock = authLockState(req);
|
|
2803
|
+
if (lock.locked) {
|
|
2804
|
+
res.set('Retry-After', String(lock.retryAfter));
|
|
2805
|
+
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
2806
|
+
}
|
|
2902
2807
|
const pk = req.query.passkey;
|
|
2903
|
-
if (!
|
|
2808
|
+
if (!verifyPasskey(pk, stored)) {
|
|
2809
|
+
if (pk) recordAuthFailure(req);
|
|
2904
2810
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
2905
2811
|
}
|
|
2906
2812
|
}
|