4bnode 4.1.3 → 4.1.5
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/add-docs.js +18 -5
- package/app-1/.4bnode/.passkey +1 -1
- package/app-1/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/app-1/.4bnode/assets/js/pages/MailPage.js +144 -54
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/app-1/.4bnode/codegen.js +247 -56
- package/app-1/.4bnode/dev-api.js +131 -5
- package/app-1/.env.example +11 -0
- package/app-1/index.js +49 -6
- package/app-1/package.json +2 -0
- package/app-1/src/docs.js +1 -1
- package/app-1/src/middleware/apiKey.js +36 -0
- package/app-1/src/middleware/auth.js +17 -0
- package/app-1/src/models/user.js +11 -0
- package/app-1/src/openapi.json +23 -5
- package/app-1/src/routes/upload-resume.js +21 -0
- package/app-1/src/services/mailer.js +52 -0
- package/app-1/src/validators/upload-resume.js +8 -0
- package/lib/codegen.js +247 -56
- package/lib/codegen.test.js +37 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +144 -54
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/skeleton/.4bnode/codegen.js +247 -56
- package/skeleton/.4bnode/dev-api.js +131 -5
- package/skeleton/.env +6 -0
- package/skeleton/.env.example +11 -0
- package/app-1/src/models/login.js +0 -9
- package/app-1/src/routes/products.js +0 -26
- package/app-1/src/validators/products.js +0 -7
- package/app-1/src/validators/user.js +0 -7
package/lib/codegen.js
CHANGED
|
@@ -363,6 +363,56 @@ export default ${camel}Schema;
|
|
|
363
363
|
`;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
// Parse a Zod validator's source into a { fieldName: isRequired } map for the
|
|
367
|
+
// top-level fields of its `z.object({ ... })`. A field is "required" for the API
|
|
368
|
+
// when its Zod chain has no `.optional()`/`.nullish()` — i.e. the request is
|
|
369
|
+
// rejected without it. This is the right source of truth for OpenAPI `required`,
|
|
370
|
+
// because the validator (not the Mongoose model) is what actually gates a request:
|
|
371
|
+
// a field can be optional in the DB schema yet required by the API, or vice versa.
|
|
372
|
+
// Returns null if no `z.object({...})` is found (caller should fall back to the model).
|
|
373
|
+
export function requiredFieldsFromZod(src) {
|
|
374
|
+
if (!src || typeof src !== "string") return null;
|
|
375
|
+
const objIdx = src.indexOf("z.object(");
|
|
376
|
+
if (objIdx === -1) return null;
|
|
377
|
+
const braceStart = src.indexOf("{", objIdx);
|
|
378
|
+
if (braceStart === -1) return null;
|
|
379
|
+
// Walk balanced brackets so nested objects/arrays/calls don't end the block early.
|
|
380
|
+
let depth = 0;
|
|
381
|
+
let end = -1;
|
|
382
|
+
for (let i = braceStart; i < src.length; i++) {
|
|
383
|
+
const c = src[i];
|
|
384
|
+
if (c === "{" || c === "(" || c === "[") depth++;
|
|
385
|
+
else if (c === "}" || c === ")" || c === "]") {
|
|
386
|
+
depth--;
|
|
387
|
+
if (depth === 0 && c === "}") { end = i; break; }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (end === -1) return null;
|
|
391
|
+
const inner = src.slice(braceStart + 1, end);
|
|
392
|
+
const map = {};
|
|
393
|
+
const flush = (entry) => {
|
|
394
|
+
const colon = entry.indexOf(":");
|
|
395
|
+
if (colon === -1) return;
|
|
396
|
+
const key = entry.slice(0, colon).trim().replace(/^['"]|['"]$/g, "");
|
|
397
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(key)) return;
|
|
398
|
+
const expr = entry.slice(colon + 1);
|
|
399
|
+
// .nullable() still requires the key (value may be null), so it doesn't count.
|
|
400
|
+
map[key] = !/\.(optional|nullish)\s*\(\s*\)/.test(expr);
|
|
401
|
+
};
|
|
402
|
+
// Split top-level "key: expr" entries on depth-0 commas.
|
|
403
|
+
let d = 0;
|
|
404
|
+
let cur = "";
|
|
405
|
+
for (let i = 0; i < inner.length; i++) {
|
|
406
|
+
const c = inner[i];
|
|
407
|
+
if (c === "{" || c === "(" || c === "[") d++;
|
|
408
|
+
else if (c === "}" || c === ")" || c === "]") d--;
|
|
409
|
+
if (c === "," && d === 0) { flush(cur); cur = ""; }
|
|
410
|
+
else cur += c;
|
|
411
|
+
}
|
|
412
|
+
if (cur.trim()) flush(cur);
|
|
413
|
+
return Object.keys(map).length ? map : null;
|
|
414
|
+
}
|
|
415
|
+
|
|
366
416
|
// Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
|
|
367
417
|
// the two needed imports. POST/PUT use the full schema; PATCH uses
|
|
368
418
|
// `<schema>.partial()` so partial updates aren't forced to send every required
|
|
@@ -419,19 +469,56 @@ export function securityWiring(features) {
|
|
|
419
469
|
|
|
420
470
|
if (has("helmet")) {
|
|
421
471
|
imports.push("import helmet from 'helmet';");
|
|
422
|
-
beforeRoutesParts.push(
|
|
472
|
+
beforeRoutesParts.push(
|
|
473
|
+
`// ── Security headers ──
|
|
474
|
+
// helmet() defaults to a strict Content-Security-Policy (script-src 'self',
|
|
475
|
+
// etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
|
|
476
|
+
// assets. We widen the policy so a typical frontend/docs page works while keeping
|
|
477
|
+
// the rest of helmet's protections. Tighten these directives for production.
|
|
478
|
+
app.use(helmet({
|
|
479
|
+
contentSecurityPolicy: {
|
|
480
|
+
directives: {
|
|
481
|
+
defaultSrc: ["'self'"],
|
|
482
|
+
scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
|
483
|
+
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
|
484
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
485
|
+
connectSrc: ["'self'"],
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
crossOriginEmbedderPolicy: false,
|
|
489
|
+
// Allow other origins (e.g. a separate frontend host) to load assets served
|
|
490
|
+
// by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
|
|
491
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
492
|
+
}));`
|
|
493
|
+
);
|
|
423
494
|
}
|
|
424
495
|
if (has("rateLimit")) {
|
|
425
496
|
imports.push("import rateLimit from 'express-rate-limit';");
|
|
426
497
|
beforeRoutesParts.push(
|
|
427
498
|
`// ── Rate limiting ──
|
|
499
|
+
// Generous global cap — high enough that real frontends (which fire many calls
|
|
500
|
+
// per page) never notice, low enough to stop scraping/abuse at scale.
|
|
501
|
+
// Window + cap are read from .env (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX) so the
|
|
502
|
+
// dashboard's Security page can tune them; fall back to 15 min / 500 requests.
|
|
428
503
|
const apiLimiter = rateLimit({
|
|
504
|
+
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
505
|
+
max: Number(process.env.RATE_LIMIT_MAX) || 500,
|
|
506
|
+
standardHeaders: true,
|
|
507
|
+
legacyHeaders: false,
|
|
508
|
+
});
|
|
509
|
+
// Strict cap for authentication routes — defends against password brute-forcing.
|
|
510
|
+
// Point this at your real auth routes (login / signup / password-reset).
|
|
511
|
+
const authLimiter = rateLimit({
|
|
429
512
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
430
|
-
max:
|
|
513
|
+
max: 10, // 10 attempts per IP per window
|
|
431
514
|
standardHeaders: true,
|
|
432
515
|
legacyHeaders: false,
|
|
433
516
|
});
|
|
434
|
-
|
|
517
|
+
// Only throttle in production — in dev, repeated/hot-reload requests shouldn't hit 429.
|
|
518
|
+
if (process.env.NODE_ENV === 'production') {
|
|
519
|
+
app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
|
|
520
|
+
app.use(apiLimiter); // generous global limiter for everything else
|
|
521
|
+
}`
|
|
435
522
|
);
|
|
436
523
|
}
|
|
437
524
|
if (has("errorHandler")) {
|
|
@@ -708,6 +795,7 @@ export const PACKAGE_VERSIONS = {
|
|
|
708
795
|
"@serialport/parser-readline": "^12.0.0",
|
|
709
796
|
mongoose: "^8.8.0",
|
|
710
797
|
multer: "^1.4.5-lts.1",
|
|
798
|
+
nodemailer: "^6.9.0",
|
|
711
799
|
};
|
|
712
800
|
|
|
713
801
|
// Add packages to package.json (dependencies, or devDependencies when dev:true).
|
|
@@ -759,6 +847,30 @@ export function extractRequestFields(handlerBody, source = "body") {
|
|
|
759
847
|
return names;
|
|
760
848
|
}
|
|
761
849
|
|
|
850
|
+
// Detect multer upload fields in a route handler so the OpenAPI builder can mark
|
|
851
|
+
// the body as multipart/form-data with the right file fields. Handles:
|
|
852
|
+
// upload.single('avatar') -> [{name:'avatar'}]
|
|
853
|
+
// upload.array('photos', 5) -> [{name:'photos'}]
|
|
854
|
+
// upload.fields([{ name: 'a' }, { name: 'b' }]) -> [{name:'a'},{name:'b'}]
|
|
855
|
+
export function extractFileFields(handlerBody) {
|
|
856
|
+
const body = handlerBody || "";
|
|
857
|
+
const out = [];
|
|
858
|
+
const seen = new Set();
|
|
859
|
+
const add = (name) => { if (name && !seen.has(name)) { seen.add(name); out.push({ name }); } };
|
|
860
|
+
(body.match(/upload\.(?:single|array)\(\s*['"]([^'"]+)['"]/g) || []).forEach((s) => {
|
|
861
|
+
const m = s.match(/['"]([^'"]+)['"]/);
|
|
862
|
+
if (m) add(m[1]);
|
|
863
|
+
});
|
|
864
|
+
const fieldsCall = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]/);
|
|
865
|
+
if (fieldsCall) {
|
|
866
|
+
(fieldsCall[1].match(/name\s*:\s*['"]([^'"]+)['"]/g) || []).forEach((n) => {
|
|
867
|
+
const m = n.match(/['"]([^'"]+)['"]/);
|
|
868
|
+
if (m) add(m[1]);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
return out;
|
|
872
|
+
}
|
|
873
|
+
|
|
762
874
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
763
875
|
// API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
|
|
764
876
|
// Reuses extractRequestFields output (endpoints) and model field metadata.
|
|
@@ -804,7 +916,9 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
|
|
|
804
916
|
const properties = {};
|
|
805
917
|
const required = [];
|
|
806
918
|
bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
|
|
807
|
-
|
|
919
|
+
// Uploaded files are required by default (an upload endpoint exists to receive
|
|
920
|
+
// the file). A file field can opt out with required:false.
|
|
921
|
+
fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; if (f.required !== false) required.push(f.name); });
|
|
808
922
|
const schema = { type: "object", properties };
|
|
809
923
|
if (required.length) schema.required = required;
|
|
810
924
|
const mime = fileFields.length ? "multipart/form-data" : "application/json";
|
|
@@ -891,21 +1005,19 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
891
1005
|
.req { color:#ef4444; font-size:11px; font-weight:700; }
|
|
892
1006
|
.opt { color:var(--faint); font-size:11px; }
|
|
893
1007
|
.resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
|
|
894
|
-
|
|
895
|
-
|
|
1008
|
+
.tabs { display:flex; gap:4px; flex-wrap:wrap; }
|
|
1009
|
+
.tab { background:var(--input); color:var(--muted); border:1px solid var(--border); border-bottom:none;
|
|
1010
|
+
border-radius:8px 8px 0 0; padding:6px 13px; font-size:12px; cursor:pointer; }
|
|
1011
|
+
.tab:hover { color:var(--text); }
|
|
1012
|
+
.tab.active { background:#0a0c12; color:var(--accent-light); }
|
|
1013
|
+
.code-wrap { position:relative; max-width:560px; }
|
|
1014
|
+
pre.code { background:#0a0c12; border:1px solid var(--border); border-radius:0 8px 8px 8px; padding:14px;
|
|
1015
|
+
font-size:12.5px; color:#d5d8e3; overflow:auto; white-space:pre; margin:0;
|
|
896
1016
|
font-family:ui-monospace,Menlo,monospace; }
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
.
|
|
901
|
-
.btn { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 18px;
|
|
902
|
-
font-size:13px; font-weight:600; cursor:pointer; }
|
|
903
|
-
.btn:hover { filter:brightness(1.08); }
|
|
904
|
-
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
|
905
|
-
pre.out { background:#0a0c12; border:1px solid var(--border); border-radius:8px; padding:12px;
|
|
906
|
-
font-size:12.5px; color:#d5d8e3; overflow:auto; max-height:360px; max-width:540px; white-space:pre-wrap;
|
|
907
|
-
word-break:break-word; margin-top:10px; }
|
|
908
|
-
.out-status { font-family:ui-monospace,Menlo,monospace; font-size:12px; margin-top:10px; }
|
|
1017
|
+
pre.code.hidden { display:none; }
|
|
1018
|
+
.copy-btn { position:absolute; top:8px; right:8px; background:var(--card); color:var(--muted);
|
|
1019
|
+
border:1px solid var(--border); border-radius:6px; padding:4px 10px; font-size:11px; cursor:pointer; z-index:2; }
|
|
1020
|
+
.copy-btn:hover { color:var(--text); }
|
|
909
1021
|
::-webkit-scrollbar { width:9px; height:9px; }
|
|
910
1022
|
::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
|
|
911
1023
|
</style>
|
|
@@ -936,6 +1048,90 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
936
1048
|
function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
|
|
937
1049
|
function collect(spec){var out=[];var paths=spec.paths||{};Object.keys(paths).forEach(function(p){var item=paths[p];Object.keys(item).forEach(function(m){out.push({path:p,method:m,op:item[m]});});});return out;}
|
|
938
1050
|
|
|
1051
|
+
// ── Client code samples (generated from the spec — no AI, no network) ──
|
|
1052
|
+
function sampleUrl(e){return (location.origin||'')+e.path;}
|
|
1053
|
+
function bodyForSample(e){var b=bodyOf(e.op);if(b&&['post','put','patch'].indexOf(e.method)>=0)return b;return null;}
|
|
1054
|
+
function indentBody(str,extra){var NL=String.fromCharCode(10);return str.split(NL).map(function(ln,i){return i===0?ln:extra+ln;}).join(NL);}
|
|
1055
|
+
function isMultipart(b){return !!(b&&b.mime==='multipart/form-data');}
|
|
1056
|
+
// Body fields with a flag for binary (file) fields, used to build multipart samples.
|
|
1057
|
+
function fieldList(b){var p=(b&&b.schema&&b.schema.properties)||{};return Object.keys(p).map(function(k){return {name:k,file:!!(p[k]&&p[k].format==='binary')};});}
|
|
1058
|
+
// FormData lines for a multipart body: files become a File object, others a string.
|
|
1059
|
+
function formLines(b){
|
|
1060
|
+
var out=["const form = new FormData();"];
|
|
1061
|
+
fieldList(b).forEach(function(f){
|
|
1062
|
+
out.push("form.append('"+f.name+"', "+(f.file?"fileInput.files[0]":"''")+");"+(f.file?" // a File from an <input type=file>":""));
|
|
1063
|
+
});
|
|
1064
|
+
return out;
|
|
1065
|
+
}
|
|
1066
|
+
function curlSample(e){
|
|
1067
|
+
var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
|
|
1068
|
+
L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
|
|
1069
|
+
if(e.op.security)L.push("-H 'Authorization: Bearer <token>'");
|
|
1070
|
+
if(b&&isMultipart(b)){
|
|
1071
|
+
// curl sets the multipart Content-Type (with boundary) itself; -F per field.
|
|
1072
|
+
fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
|
|
1073
|
+
} else if(b){
|
|
1074
|
+
L.push("-H 'Content-Type: application/json'");
|
|
1075
|
+
var c;try{c=JSON.stringify(JSON.parse(bodySkeleton(b.schema)));}catch(x){c=bodySkeleton(b.schema);}
|
|
1076
|
+
L.push("-d '"+c+"'");
|
|
1077
|
+
}
|
|
1078
|
+
return L.join(" "+BS+NL+" ");
|
|
1079
|
+
}
|
|
1080
|
+
function fetchSample(e){
|
|
1081
|
+
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
|
|
1082
|
+
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
|
|
1083
|
+
L.push("const res = await fetch('"+sampleUrl(e)+"', {");
|
|
1084
|
+
L.push(" method: '"+e.method.toUpperCase()+"',");
|
|
1085
|
+
var h=[];
|
|
1086
|
+
// For multipart, never set Content-Type by hand — the browser adds the boundary.
|
|
1087
|
+
if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
|
|
1088
|
+
if(e.op.security)h.push(" 'Authorization': 'Bearer <token>'");
|
|
1089
|
+
if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
|
|
1090
|
+
if(mp)L.push(" body: form");
|
|
1091
|
+
else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
|
|
1092
|
+
L.push("});");
|
|
1093
|
+
L.push("const data = await res.json();");
|
|
1094
|
+
L.push("console.log(data);");
|
|
1095
|
+
return L.join(NL);
|
|
1096
|
+
}
|
|
1097
|
+
function axiosSample(e){
|
|
1098
|
+
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
|
|
1099
|
+
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
|
|
1100
|
+
L.push("const res = await axios({");
|
|
1101
|
+
L.push(" method: '"+e.method.toLowerCase()+"',");
|
|
1102
|
+
L.push(" url: '"+sampleUrl(e)+"',");
|
|
1103
|
+
if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
|
|
1104
|
+
if(mp)L.push(" data: form");
|
|
1105
|
+
else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
|
|
1106
|
+
L.push("});");
|
|
1107
|
+
L.push("console.log(res.data);");
|
|
1108
|
+
return L.join(NL);
|
|
1109
|
+
}
|
|
1110
|
+
function jquerySample(e){
|
|
1111
|
+
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
|
|
1112
|
+
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
|
|
1113
|
+
L.push("$.ajax({");
|
|
1114
|
+
L.push(" url: '"+sampleUrl(e)+"',");
|
|
1115
|
+
L.push(" method: '"+e.method.toUpperCase()+"',");
|
|
1116
|
+
if(mp){L.push(" data: form,");L.push(" processData: false,");L.push(" contentType: false,");}
|
|
1117
|
+
else if(b)L.push(" contentType: 'application/json',");
|
|
1118
|
+
if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
|
|
1119
|
+
if(b&&!mp)L.push(" data: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+"),");
|
|
1120
|
+
L.push(" success: function(data) {");
|
|
1121
|
+
L.push(" console.log(data);");
|
|
1122
|
+
L.push(" }");
|
|
1123
|
+
L.push("});");
|
|
1124
|
+
return L.join(NL);
|
|
1125
|
+
}
|
|
1126
|
+
function buildSamples(e){
|
|
1127
|
+
return [
|
|
1128
|
+
{lang:'curl',label:'cURL',code:curlSample(e)},
|
|
1129
|
+
{lang:'fetch',label:'JavaScript (fetch)',code:fetchSample(e)},
|
|
1130
|
+
{lang:'axios',label:'axios',code:axiosSample(e)},
|
|
1131
|
+
{lang:'jquery',label:'jQuery',code:jquerySample(e)}
|
|
1132
|
+
];
|
|
1133
|
+
}
|
|
1134
|
+
|
|
939
1135
|
function detailsHtml(e,id){
|
|
940
1136
|
var html='';
|
|
941
1137
|
var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
|
|
@@ -957,44 +1153,34 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
957
1153
|
Object.keys(resp).forEach(function(code){
|
|
958
1154
|
html+='<div class="row"><span class="resp-code">'+esc(code)+'</span><span class="detail-sum" style="margin:0">'+esc((resp[code]||{}).description||'')+'</span></div>';
|
|
959
1155
|
});
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
html+='<
|
|
966
|
-
html+='<
|
|
967
|
-
html+='
|
|
1156
|
+
var samples=buildSamples(e);
|
|
1157
|
+
html+='<div class="sec">Code samples</div>';
|
|
1158
|
+
html+='<div class="tabs" id="'+id+'_tabs">';
|
|
1159
|
+
samples.forEach(function(s,i){html+='<button class="tab'+(i===0?' active':'')+'" data-lang="'+s.lang+'">'+esc(s.label)+'</button>';});
|
|
1160
|
+
html+='</div>';
|
|
1161
|
+
html+='<div class="code-wrap"><button class="copy-btn">Copy</button>';
|
|
1162
|
+
samples.forEach(function(s,i){html+='<pre class="code'+(i===0?'':' hidden')+'" data-lang="'+s.lang+'">'+esc(s.code)+'</pre>';});
|
|
1163
|
+
html+='</div>';
|
|
968
1164
|
return html;
|
|
969
1165
|
}
|
|
970
1166
|
|
|
971
|
-
function
|
|
972
|
-
var
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
var
|
|
984
|
-
|
|
985
|
-
if(
|
|
986
|
-
var
|
|
987
|
-
|
|
988
|
-
var bodyEl=root.querySelector('#'+id+'_body');
|
|
989
|
-
if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
|
|
990
|
-
var statusEl=root.querySelector('#'+id+'_status');var outEl=root.querySelector('#'+id+'_out');
|
|
991
|
-
btn.disabled=true;statusEl.textContent='Sending...';var t0=Date.now();
|
|
992
|
-
fetch(url,opts).then(function(r){var ct=r.headers.get('content-type')||'';return r.text().then(function(txt){return {r:r,txt:txt,ct:ct};});}).then(function(d){
|
|
993
|
-
var color=d.r.ok?'#22c55e':'#ef4444';
|
|
994
|
-
statusEl.innerHTML='<span style="color:'+color+'">'+d.r.status+' '+esc(d.r.statusText)+'</span> · '+(Date.now()-t0)+'ms';
|
|
995
|
-
var out=d.txt;if(d.ct.indexOf('application/json')>=0){try{out=JSON.stringify(JSON.parse(d.txt),null,2);}catch(err){}}
|
|
996
|
-
outEl.style.display='block';outEl.textContent=out;
|
|
997
|
-
}).catch(function(err){statusEl.innerHTML='<span style="color:#ef4444">Request failed</span>';outEl.style.display='block';outEl.textContent=String(err);}).then(function(){btn.disabled=false;});
|
|
1167
|
+
function wireSamples(id,root){
|
|
1168
|
+
var tabs=root.querySelector('#'+id+'_tabs');if(!tabs)return;
|
|
1169
|
+
var btns=tabs.querySelectorAll('.tab');
|
|
1170
|
+
btns.forEach(function(b){
|
|
1171
|
+
b.addEventListener('click',function(){
|
|
1172
|
+
var lang=b.getAttribute('data-lang');
|
|
1173
|
+
btns.forEach(function(x){x.classList.remove('active');});b.classList.add('active');
|
|
1174
|
+
root.querySelectorAll('pre.code[data-lang]').forEach(function(p){p.classList.toggle('hidden',p.getAttribute('data-lang')!==lang);});
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
var copy=root.querySelector('.copy-btn');
|
|
1178
|
+
if(copy)copy.addEventListener('click',function(){
|
|
1179
|
+
var active=root.querySelector('pre.code[data-lang]:not(.hidden)');if(!active)return;
|
|
1180
|
+
var txt=active.textContent;
|
|
1181
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt);}
|
|
1182
|
+
else{var ta=document.createElement('textarea');ta.value=txt;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(x){}document.body.removeChild(ta);}
|
|
1183
|
+
copy.textContent='Copied';setTimeout(function(){copy.textContent='Copy';},1200);
|
|
998
1184
|
});
|
|
999
1185
|
}
|
|
1000
1186
|
|
|
@@ -1009,7 +1195,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1009
1195
|
html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
|
|
1010
1196
|
html+=detailsHtml(e,id);
|
|
1011
1197
|
main.innerHTML=html;main.scrollTop=0;
|
|
1012
|
-
|
|
1198
|
+
wireSamples(id,main);
|
|
1013
1199
|
}
|
|
1014
1200
|
|
|
1015
1201
|
function render(spec){
|
|
@@ -1187,9 +1373,9 @@ export default router;
|
|
|
1187
1373
|
// Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
|
|
1188
1374
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1189
1375
|
export const MAIL_PROVIDERS = {
|
|
1376
|
+
mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
|
|
1190
1377
|
brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
|
|
1191
1378
|
sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
|
|
1192
|
-
mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
|
|
1193
1379
|
gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
|
|
1194
1380
|
mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
|
|
1195
1381
|
smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
|
|
@@ -1227,14 +1413,19 @@ export function emailTemplate({ title = '', body = '' } = {}) {
|
|
|
1227
1413
|
}
|
|
1228
1414
|
|
|
1229
1415
|
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
1230
|
-
|
|
1416
|
+
// attachments: [{ filename, path }] or [{ filename, content }] (nodemailer format).
|
|
1417
|
+
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
|
|
1231
1418
|
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
1232
1419
|
return transporter.sendMail({
|
|
1233
1420
|
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
1234
1421
|
to,
|
|
1422
|
+
cc,
|
|
1423
|
+
bcc,
|
|
1424
|
+
replyTo,
|
|
1235
1425
|
subject,
|
|
1236
1426
|
text,
|
|
1237
1427
|
html,
|
|
1428
|
+
attachments,
|
|
1238
1429
|
});
|
|
1239
1430
|
}
|
|
1240
1431
|
|
package/lib/codegen.test.js
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
buildRolesGuardFile,
|
|
24
24
|
buildValidateFile,
|
|
25
25
|
buildZodSchemaFile,
|
|
26
|
+
requiredFieldsFromZod,
|
|
27
|
+
extractFileFields,
|
|
26
28
|
wireValidateIntoRouteContent,
|
|
27
29
|
buildAuthMiddleware,
|
|
28
30
|
wireAuthIntoRouteContent,
|
|
@@ -140,7 +142,8 @@ test("securityWiring composes imports + insertion blocks", () => {
|
|
|
140
142
|
assert.ok(w.imports.includes("import helmet from 'helmet';"));
|
|
141
143
|
assert.ok(w.imports.includes("import rateLimit from 'express-rate-limit';"));
|
|
142
144
|
assert.ok(w.imports.some((i) => i.includes("errorHandler.js")));
|
|
143
|
-
assert.ok(w.beforeRoutes.includes("app.use(helmet(
|
|
145
|
+
assert.ok(w.beforeRoutes.includes("app.use(helmet("));
|
|
146
|
+
assert.ok(w.beforeRoutes.includes("contentSecurityPolicy:"));
|
|
144
147
|
assert.ok(w.beforeRoutes.includes("rateLimit({"));
|
|
145
148
|
assert.ok(w.beforeListen.includes("app.use(notFound)"));
|
|
146
149
|
assert.ok(w.beforeListen.includes("app.use(errorHandler)"));
|
|
@@ -174,6 +177,35 @@ test("buildZodSchemaFile maps mongoose types and optionality", () => {
|
|
|
174
177
|
assert.ok(out.includes("export const userSchema"));
|
|
175
178
|
});
|
|
176
179
|
|
|
180
|
+
test("extractFileFields detects multer single/array/fields", () => {
|
|
181
|
+
assert.deepEqual(extractFileFields("upload.single('resume'), async"), [{ name: "resume" }]);
|
|
182
|
+
assert.deepEqual(extractFileFields("upload.array('photos', 5),"), [{ name: "photos" }]);
|
|
183
|
+
assert.deepEqual(
|
|
184
|
+
extractFileFields("upload.fields([{ name: 'a', maxCount: 1 }, { name: 'b' }]),"),
|
|
185
|
+
[{ name: "a" }, { name: "b" }]
|
|
186
|
+
);
|
|
187
|
+
assert.deepEqual(extractFileFields("async (req, res) => { res.json({}); }"), []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("requiredFieldsFromZod reads required-ness from .optional()", () => {
|
|
191
|
+
// Round-trip the actual generated validator format.
|
|
192
|
+
const src = buildZodSchemaFile("user", [
|
|
193
|
+
{ name: "email", type: "String", required: true },
|
|
194
|
+
{ name: "age", type: "Number" },
|
|
195
|
+
{ name: "owner", type: "ObjectId", required: true },
|
|
196
|
+
]);
|
|
197
|
+
const req = requiredFieldsFromZod(src);
|
|
198
|
+
assert.equal(req.email, true); // no .optional() → required
|
|
199
|
+
assert.equal(req.age, false); // .optional() → not required
|
|
200
|
+
assert.equal(req.owner, true); // chained .regex() but no .optional() → required
|
|
201
|
+
// Handles nested objects/arrays without ending the block early.
|
|
202
|
+
const nested = `z.object({ a: z.string(), b: z.array(z.object({ c: z.number() })).optional(), d: z.string() })`;
|
|
203
|
+
const r2 = requiredFieldsFromZod(nested);
|
|
204
|
+
assert.deepEqual(r2, { a: true, b: false, d: true });
|
|
205
|
+
// No z.object → null so callers fall back to the model.
|
|
206
|
+
assert.equal(requiredFieldsFromZod("export const x = 1;"), null);
|
|
207
|
+
});
|
|
208
|
+
|
|
177
209
|
test("wireValidateIntoRouteContent guards write endpoints only, idempotently", () => {
|
|
178
210
|
const route = [
|
|
179
211
|
"import express from 'express';",
|
|
@@ -447,6 +479,10 @@ test("buildOpenApiSpec uses multipart when file fields present", () => {
|
|
|
447
479
|
const spec = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar" }] }] });
|
|
448
480
|
const schema = spec.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema;
|
|
449
481
|
assert.deepEqual(schema.properties.avatar, { type: "string", format: "binary" });
|
|
482
|
+
assert.deepEqual(schema.required, ["avatar"]); // files are required by default
|
|
483
|
+
// ...unless the field opts out
|
|
484
|
+
const optional = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar", required: false }] }] });
|
|
485
|
+
assert.equal(optional.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema.required, undefined);
|
|
450
486
|
});
|
|
451
487
|
|
|
452
488
|
test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, no Swagger)", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4bnode",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.5",
|
|
4
4
|
"description": "4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@ export function Sidebar() {
|
|
|
29
29
|
lockMsg: "Requires database",
|
|
30
30
|
},
|
|
31
31
|
{ id: "routes", label: "API Routes", icon: Icons.route },
|
|
32
|
-
|
|
32
|
+
{ id: "docs", label: "API Docs", icon: Icons.docs },
|
|
33
33
|
{ id: "api-tester", label: "API Tester", icon: Icons.apiTester },
|
|
34
34
|
{
|
|
35
35
|
id: "security",
|
|
@@ -39,7 +39,7 @@ export function Sidebar() {
|
|
|
39
39
|
},
|
|
40
40
|
{ id: "realtime", label: "Real-time", icon: Icons.realtime },
|
|
41
41
|
{ id: "serialport", label: "SerialPort", icon: Icons.serial },
|
|
42
|
-
|
|
42
|
+
{ id: "mail", label: "Email", icon: Icons.mail },
|
|
43
43
|
{ id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
|
|
44
44
|
{ id: "env", label: "Environment", icon: Icons.env },
|
|
45
45
|
];
|