4bnode 4.1.4 → 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 +225 -55
- package/lib/codegen.test.js +35 -0
- 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 +225 -55
- 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
|
@@ -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
|
|
@@ -446,13 +496,29 @@ app.use(helmet({
|
|
|
446
496
|
imports.push("import rateLimit from 'express-rate-limit';");
|
|
447
497
|
beforeRoutesParts.push(
|
|
448
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.
|
|
449
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({
|
|
450
512
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
451
|
-
max:
|
|
513
|
+
max: 10, // 10 attempts per IP per window
|
|
452
514
|
standardHeaders: true,
|
|
453
515
|
legacyHeaders: false,
|
|
454
516
|
});
|
|
455
|
-
|
|
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
|
+
}`
|
|
456
522
|
);
|
|
457
523
|
}
|
|
458
524
|
if (has("errorHandler")) {
|
|
@@ -729,6 +795,7 @@ export const PACKAGE_VERSIONS = {
|
|
|
729
795
|
"@serialport/parser-readline": "^12.0.0",
|
|
730
796
|
mongoose: "^8.8.0",
|
|
731
797
|
multer: "^1.4.5-lts.1",
|
|
798
|
+
nodemailer: "^6.9.0",
|
|
732
799
|
};
|
|
733
800
|
|
|
734
801
|
// Add packages to package.json (dependencies, or devDependencies when dev:true).
|
|
@@ -780,6 +847,30 @@ export function extractRequestFields(handlerBody, source = "body") {
|
|
|
780
847
|
return names;
|
|
781
848
|
}
|
|
782
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
|
+
|
|
783
874
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
784
875
|
// API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
|
|
785
876
|
// Reuses extractRequestFields output (endpoints) and model field metadata.
|
|
@@ -825,7 +916,9 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
|
|
|
825
916
|
const properties = {};
|
|
826
917
|
const required = [];
|
|
827
918
|
bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
|
|
828
|
-
|
|
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); });
|
|
829
922
|
const schema = { type: "object", properties };
|
|
830
923
|
if (required.length) schema.required = required;
|
|
831
924
|
const mime = fileFields.length ? "multipart/form-data" : "application/json";
|
|
@@ -912,21 +1005,19 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
912
1005
|
.req { color:#ef4444; font-size:11px; font-weight:700; }
|
|
913
1006
|
.opt { color:var(--faint); font-size:11px; }
|
|
914
1007
|
.resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
|
|
915
|
-
|
|
916
|
-
|
|
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;
|
|
917
1016
|
font-family:ui-monospace,Menlo,monospace; }
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
.
|
|
922
|
-
.btn { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 18px;
|
|
923
|
-
font-size:13px; font-weight:600; cursor:pointer; }
|
|
924
|
-
.btn:hover { filter:brightness(1.08); }
|
|
925
|
-
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
|
926
|
-
pre.out { background:#0a0c12; border:1px solid var(--border); border-radius:8px; padding:12px;
|
|
927
|
-
font-size:12.5px; color:#d5d8e3; overflow:auto; max-height:360px; max-width:540px; white-space:pre-wrap;
|
|
928
|
-
word-break:break-word; margin-top:10px; }
|
|
929
|
-
.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); }
|
|
930
1021
|
::-webkit-scrollbar { width:9px; height:9px; }
|
|
931
1022
|
::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
|
|
932
1023
|
</style>
|
|
@@ -957,6 +1048,90 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
957
1048
|
function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
|
|
958
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;}
|
|
959
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
|
+
|
|
960
1135
|
function detailsHtml(e,id){
|
|
961
1136
|
var html='';
|
|
962
1137
|
var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
|
|
@@ -978,44 +1153,34 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
978
1153
|
Object.keys(resp).forEach(function(code){
|
|
979
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>';
|
|
980
1155
|
});
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
html+='<
|
|
987
|
-
html+='<
|
|
988
|
-
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>';
|
|
989
1164
|
return html;
|
|
990
1165
|
}
|
|
991
1166
|
|
|
992
|
-
function
|
|
993
|
-
var
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
var
|
|
1005
|
-
|
|
1006
|
-
if(
|
|
1007
|
-
var
|
|
1008
|
-
|
|
1009
|
-
var bodyEl=root.querySelector('#'+id+'_body');
|
|
1010
|
-
if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}
|
|
1011
|
-
var statusEl=root.querySelector('#'+id+'_status');var outEl=root.querySelector('#'+id+'_out');
|
|
1012
|
-
btn.disabled=true;statusEl.textContent='Sending...';var t0=Date.now();
|
|
1013
|
-
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){
|
|
1014
|
-
var color=d.r.ok?'#22c55e':'#ef4444';
|
|
1015
|
-
statusEl.innerHTML='<span style="color:'+color+'">'+d.r.status+' '+esc(d.r.statusText)+'</span> · '+(Date.now()-t0)+'ms';
|
|
1016
|
-
var out=d.txt;if(d.ct.indexOf('application/json')>=0){try{out=JSON.stringify(JSON.parse(d.txt),null,2);}catch(err){}}
|
|
1017
|
-
outEl.style.display='block';outEl.textContent=out;
|
|
1018
|
-
}).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);
|
|
1019
1184
|
});
|
|
1020
1185
|
}
|
|
1021
1186
|
|
|
@@ -1030,7 +1195,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1030
1195
|
html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
|
|
1031
1196
|
html+=detailsHtml(e,id);
|
|
1032
1197
|
main.innerHTML=html;main.scrollTop=0;
|
|
1033
|
-
|
|
1198
|
+
wireSamples(id,main);
|
|
1034
1199
|
}
|
|
1035
1200
|
|
|
1036
1201
|
function render(spec){
|
|
@@ -1208,9 +1373,9 @@ export default router;
|
|
|
1208
1373
|
// Email (nodemailer) — SMTP with presets for popular providers (Brevo, etc.).
|
|
1209
1374
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1210
1375
|
export const MAIL_PROVIDERS = {
|
|
1376
|
+
mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
|
|
1211
1377
|
brevo: { label: "Brevo", host: "smtp-relay.brevo.com", port: 587, secure: false, hint: "Login email + SMTP key (SMTP & API → SMTP)" },
|
|
1212
1378
|
sendgrid: { label: "SendGrid", host: "smtp.sendgrid.net", port: 587, secure: false, user: "apikey", hint: "User is literally 'apikey'; pass is your API key" },
|
|
1213
|
-
mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
|
|
1214
1379
|
gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
|
|
1215
1380
|
mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
|
|
1216
1381
|
smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
|
|
@@ -1248,14 +1413,19 @@ export function emailTemplate({ title = '', body = '' } = {}) {
|
|
|
1248
1413
|
}
|
|
1249
1414
|
|
|
1250
1415
|
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
1251
|
-
|
|
1416
|
+
// attachments: [{ filename, path }] or [{ filename, content }] (nodemailer format).
|
|
1417
|
+
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
|
|
1252
1418
|
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
1253
1419
|
return transporter.sendMail({
|
|
1254
1420
|
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
1255
1421
|
to,
|
|
1422
|
+
cc,
|
|
1423
|
+
bcc,
|
|
1424
|
+
replyTo,
|
|
1256
1425
|
subject,
|
|
1257
1426
|
text,
|
|
1258
1427
|
html,
|
|
1428
|
+
attachments,
|
|
1259
1429
|
});
|
|
1260
1430
|
}
|
|
1261
1431
|
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
buildCrudRouteFile,
|
|
35
35
|
addDepsToPackageJson,
|
|
36
36
|
extractRequestFields,
|
|
37
|
+
extractFileFields,
|
|
38
|
+
requiredFieldsFromZod,
|
|
37
39
|
buildOpenApiSpec,
|
|
38
40
|
buildDocsRouter,
|
|
39
41
|
MAIL_PROVIDERS,
|
|
@@ -683,8 +685,21 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
683
685
|
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
684
686
|
}
|
|
685
687
|
|
|
688
|
+
// Find the Zod validator this route uses (if any) before deleting the route,
|
|
689
|
+
// so we can clean it up too — otherwise validators/<name>.js is orphaned.
|
|
690
|
+
let validatorPath = null;
|
|
691
|
+
try {
|
|
692
|
+
const routeContent = fs.readFileSync(filePath, 'utf8');
|
|
693
|
+
const vi = routeContent.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
|
|
694
|
+
if (vi) validatorPath = path.join(process.cwd(), 'src', 'validators', vi[1] + '.js');
|
|
695
|
+
} catch {}
|
|
696
|
+
|
|
686
697
|
// Delete the route file
|
|
687
698
|
fs.unlinkSync(filePath);
|
|
699
|
+
// Delete its validator (only this route used it — validators are 1:1 with routes).
|
|
700
|
+
if (validatorPath && fs.existsSync(validatorPath)) {
|
|
701
|
+
try { fs.unlinkSync(validatorPath); } catch {}
|
|
702
|
+
}
|
|
688
703
|
refreshDocsSpec();
|
|
689
704
|
|
|
690
705
|
// Send response BEFORE modifying index.js (nodemon restart kills connection)
|
|
@@ -1220,7 +1235,7 @@ router.get('/api/security/rate-limit', (req, res) => {
|
|
|
1220
1235
|
try {
|
|
1221
1236
|
const envPath = path.join(process.cwd(), '.env');
|
|
1222
1237
|
const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
|
|
1223
|
-
const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) ||
|
|
1238
|
+
const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 500;
|
|
1224
1239
|
res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
|
|
1225
1240
|
} catch (err) {
|
|
1226
1241
|
res.status(500).json({ error: err.message });
|
|
@@ -1230,7 +1245,7 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1230
1245
|
try {
|
|
1231
1246
|
const envPath = path.join(process.cwd(), '.env');
|
|
1232
1247
|
const windowMinutes = Math.max(1, Math.min(1440, Number(req.body && req.body.windowMinutes) || 15));
|
|
1233
|
-
const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) ||
|
|
1248
|
+
const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 500));
|
|
1234
1249
|
updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
|
|
1235
1250
|
updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
|
|
1236
1251
|
res.json({ message: 'Rate limit updated.', windowMinutes, max });
|
|
@@ -1239,6 +1254,94 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1239
1254
|
}
|
|
1240
1255
|
});
|
|
1241
1256
|
|
|
1257
|
+
// ---------------------------------------------------------------------------
|
|
1258
|
+
// Email (nodemailer) — provider presets, SMTP config in .env, install + test
|
|
1259
|
+
// ---------------------------------------------------------------------------
|
|
1260
|
+
const mailerFilePath = () => path.join(process.cwd(), 'src', 'services', 'mailer.js');
|
|
1261
|
+
|
|
1262
|
+
// GET /api/mail — current config + provider presets for the Email page.
|
|
1263
|
+
router.get('/api/mail', (req, res) => {
|
|
1264
|
+
try {
|
|
1265
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1266
|
+
res.json({
|
|
1267
|
+
configured: fs.existsSync(mailerFilePath()),
|
|
1268
|
+
providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
|
|
1269
|
+
host: getEnvValue(envPath, 'SMTP_HOST'),
|
|
1270
|
+
port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
|
|
1271
|
+
secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
|
|
1272
|
+
user: getEnvValue(envPath, 'SMTP_USER'),
|
|
1273
|
+
from: getEnvValue(envPath, 'SMTP_FROM'),
|
|
1274
|
+
hasPass: !!getEnvValue(envPath, 'SMTP_PASS'),
|
|
1275
|
+
});
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
res.status(500).json({ error: err.message });
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
// POST /api/mail/setup — write src/services/mailer.js, save SMTP_* to .env, install nodemailer.
|
|
1282
|
+
router.post('/api/mail/setup', async (req, res) => {
|
|
1283
|
+
try {
|
|
1284
|
+
const { host, port, secure, user, pass, from } = req.body || {};
|
|
1285
|
+
if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
|
|
1286
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1287
|
+
|
|
1288
|
+
// Persist SMTP config to .env first (a .env write does NOT restart nodemon).
|
|
1289
|
+
// A blank password keeps the saved one.
|
|
1290
|
+
updateEnvFile(envPath, 'SMTP_HOST', String(host).trim());
|
|
1291
|
+
updateEnvFile(envPath, 'SMTP_PORT', String(port || 587).trim());
|
|
1292
|
+
updateEnvFile(envPath, 'SMTP_SECURE', secure ? 'true' : 'false');
|
|
1293
|
+
updateEnvFile(envPath, 'SMTP_USER', String(user || '').trim());
|
|
1294
|
+
if (pass) updateEnvFile(envPath, 'SMTP_PASS', String(pass));
|
|
1295
|
+
updateEnvFile(envPath, 'SMTP_FROM', String(from || user || '').trim());
|
|
1296
|
+
|
|
1297
|
+
// Install nodemailer BEFORE writing mailer.js. Writing into src/ restarts
|
|
1298
|
+
// nodemon, which would kill an in-flight npm install — so finish it first.
|
|
1299
|
+
await installDeps('nodemailer');
|
|
1300
|
+
|
|
1301
|
+
// Write the mailer service last (idempotent — keep an existing one). This is
|
|
1302
|
+
// the step that triggers the nodemon restart, by which point install is done.
|
|
1303
|
+
fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
|
|
1304
|
+
if (!fs.existsSync(mailerFilePath())) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
|
|
1305
|
+
|
|
1306
|
+
res.json({ message: 'Email configured. Use sendMail({ to, subject, html }) from src/services/mailer.js.' });
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
// POST /api/mail/test — send a test email using the saved SMTP settings.
|
|
1313
|
+
router.post('/api/mail/test', async (req, res) => {
|
|
1314
|
+
try {
|
|
1315
|
+
const to = String((req.body && req.body.to) || '').trim();
|
|
1316
|
+
if (!to) return res.status(400).json({ error: 'Recipient is required.' });
|
|
1317
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1318
|
+
const host = getEnvValue(envPath, 'SMTP_HOST');
|
|
1319
|
+
if (!host) return res.status(400).json({ error: 'Email is not configured yet — save SMTP settings first.' });
|
|
1320
|
+
|
|
1321
|
+
let nodemailer;
|
|
1322
|
+
try { nodemailer = (await import('nodemailer')).default; }
|
|
1323
|
+
catch { return res.status(400).json({ error: 'nodemailer is still installing. Wait a moment after saving, then retry.' }); }
|
|
1324
|
+
|
|
1325
|
+
// Build the transport from .env directly (process.env may be stale post-setup).
|
|
1326
|
+
const transporter = nodemailer.createTransport({
|
|
1327
|
+
host,
|
|
1328
|
+
port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
|
|
1329
|
+
secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
|
|
1330
|
+
auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
|
|
1331
|
+
});
|
|
1332
|
+
await transporter.sendMail({
|
|
1333
|
+
from: getEnvValue(envPath, 'SMTP_FROM') || getEnvValue(envPath, 'SMTP_USER'),
|
|
1334
|
+
to,
|
|
1335
|
+
subject: 'Test email from your app',
|
|
1336
|
+
text: 'This is a test email confirming your SMTP settings work.',
|
|
1337
|
+
html: '<p>This is a <b>test email</b> confirming your SMTP settings work.</p>',
|
|
1338
|
+
});
|
|
1339
|
+
res.json({ message: `Test email sent to ${to}.` });
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
res.status(500).json({ error: 'Send failed: ' + err.message });
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1242
1345
|
// ---------------------------------------------------------------------------
|
|
1243
1346
|
// AI Builder — describe a backend in English, generate models + CRUD routes
|
|
1244
1347
|
// ---------------------------------------------------------------------------
|
|
@@ -1523,21 +1626,44 @@ function collectEndpoints() {
|
|
|
1523
1626
|
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
1524
1627
|
const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
|
|
1525
1628
|
|
|
1629
|
+
// The API's real required-ness comes from the Zod validator (what actually
|
|
1630
|
+
// gates the request), not the Mongoose model. Parse the route's validator file
|
|
1631
|
+
// so OpenAPI `required` reflects the API contract, not the DB schema.
|
|
1632
|
+
const validatorImport = content.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
|
|
1633
|
+
let validatorReq = null;
|
|
1634
|
+
if (validatorImport) {
|
|
1635
|
+
const vPath = path.join(process.cwd(), 'src', 'validators', validatorImport[1] + '.js');
|
|
1636
|
+
if (fs.existsSync(vPath)) {
|
|
1637
|
+
try { validatorReq = requiredFieldsFromZod(fs.readFileSync(vPath, 'utf8')); } catch {}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1526
1641
|
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
|
|
1527
1642
|
let m;
|
|
1528
1643
|
while ((m = epRegex.exec(content)) !== null) {
|
|
1529
1644
|
const method = m[1].toUpperCase();
|
|
1530
1645
|
const epath = m[2];
|
|
1531
1646
|
const body = m[3] || '';
|
|
1647
|
+
// PATCH wires `validate(schema.partial())`, making every field optional.
|
|
1648
|
+
const usesPartial = /\.partial\s*\(\s*\)/.test(body);
|
|
1532
1649
|
const bodyFields = extractRequestFields(body, 'body').map((fn) => {
|
|
1533
1650
|
const meta = modelFields.find((x) => x.name === fn);
|
|
1534
|
-
|
|
1651
|
+
let required;
|
|
1652
|
+
if (usesPartial) required = false;
|
|
1653
|
+
else if (validatorReq && Object.prototype.hasOwnProperty.call(validatorReq, fn)) required = validatorReq[fn];
|
|
1654
|
+
else required = !!(meta && meta.required);
|
|
1655
|
+
return { name: fn, type: (meta && meta.type) || 'String', required };
|
|
1535
1656
|
});
|
|
1536
1657
|
const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
|
|
1537
1658
|
const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
|
|
1538
|
-
|
|
1659
|
+
// Auth is detected by the `auth` middleware on the route — NOT by scanning the
|
|
1660
|
+
// handler for "jwt"/"bearer", which would wrongly flag login/register routes
|
|
1661
|
+
// (they *sign* a JWT to return it; they don't *require* a Bearer token).
|
|
1662
|
+
const hasAuth = /\bauth\b\s*,/.test(body);
|
|
1663
|
+
// Detect multer file fields so the docs show a multipart upload + file inputs.
|
|
1664
|
+
const fileFields = extractFileFields(body);
|
|
1539
1665
|
const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
|
|
1540
|
-
out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields
|
|
1666
|
+
out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields, hasAuth });
|
|
1541
1667
|
}
|
|
1542
1668
|
});
|
|
1543
1669
|
return out;
|
package/skeleton/.env
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copy this file to .env and fill in real values.
|
|
2
|
+
# cp .env.example .env
|
|
3
|
+
|
|
4
|
+
# Port the server listens on
|
|
5
|
+
PORT=3000
|
|
6
|
+
|
|
7
|
+
# Rate limiting (only enforced when NODE_ENV=production)
|
|
8
|
+
# RATE_LIMIT_WINDOW_MS = window length in ms (900000 = 15 minutes)
|
|
9
|
+
# RATE_LIMIT_MAX = max requests per IP per window
|
|
10
|
+
RATE_LIMIT_WINDOW_MS=900000
|
|
11
|
+
RATE_LIMIT_MAX=500
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { productsSchema } from '../validators/products.js';
|
|
2
|
-
import validate from '../middleware/validate.js';
|
|
3
|
-
import express from 'express';
|
|
4
|
-
import jwt from 'jsonwebtoken';
|
|
5
|
-
import Login from '../models/login.js';
|
|
6
|
-
|
|
7
|
-
const router = express.Router();
|
|
8
|
-
|
|
9
|
-
// Register
|
|
10
|
-
router.post('/', validate(productsSchema), async (req, res) => {
|
|
11
|
-
try {
|
|
12
|
-
const { first_name } = req.body;
|
|
13
|
-
const existing = await Login.findOne({ first_name });
|
|
14
|
-
if (existing) return res.status(400).json({ message: 'Login already exists' });
|
|
15
|
-
|
|
16
|
-
const item = new Login({ first_name });
|
|
17
|
-
const saved = await item.save();
|
|
18
|
-
|
|
19
|
-
const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
|
20
|
-
res.status(201).json({ token, login: { id: saved._id, first_name: saved.first_name } });
|
|
21
|
-
} catch (err) {
|
|
22
|
-
res.status(400).json({ message: err.message });
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
export default router;
|