4bnode 4.1.4 → 4.1.6
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-api.js +5 -3
- package/add-docs.js +18 -5
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +408 -104
- package/lib/codegen.test.js +122 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
- 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 +408 -104
- package/skeleton/.4bnode/dev-api.js +384 -30
- package/skeleton/.env +6 -0
- package/skeleton/.env.example +25 -0
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +7 -2
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1249
- package/app-1/.4bnode/dev-api.js +0 -3621
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -145
- package/app-1/package.json +0 -35
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/login.js +0 -9
- package/app-1/src/openapi.json +0 -62
- 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
|
@@ -40,6 +40,25 @@ export function sanitizeName(input) {
|
|
|
40
40
|
return trimmed;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Validate a model/route FIELD name before it is interpolated into generated code.
|
|
44
|
+
// A field name becomes a JS identifier (destructuring, object keys), so anything
|
|
45
|
+
// but a plain identifier could break out of the template or pollute a prototype.
|
|
46
|
+
export function sanitizeFieldName(input) {
|
|
47
|
+
if (!input || typeof input !== "string") {
|
|
48
|
+
throw new Error("Field name is required.");
|
|
49
|
+
}
|
|
50
|
+
const trimmed = input.trim();
|
|
51
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid field name "${trimmed}". Use letters, numbers, and underscores; start with a letter or underscore.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (trimmed === "__proto__" || trimmed === "constructor" || trimmed === "prototype") {
|
|
57
|
+
throw new Error(`Field name "${trimmed}" is reserved.`);
|
|
58
|
+
}
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// ── Pure string helpers for route files ─────────────────────────────────────
|
|
44
63
|
// These operate on file CONTENT (a string) and return new content. The
|
|
45
64
|
// file-reading/writing wrappers live in lib/routeFile.js (CLI) and inline in
|
|
@@ -76,7 +95,20 @@ export function insertCodeIntoContent(content, codeBlock) {
|
|
|
76
95
|
|
|
77
96
|
// ── CRUD route builders ─────────────────────────────────────────────────────
|
|
78
97
|
|
|
98
|
+
// Field names that must never be mass-assigned from a request body — privilege
|
|
99
|
+
// flags and prototype-pollution keys. Excluded from generated create/update DTOs
|
|
100
|
+
// by default; set these server-side (e.g. in an admin route) instead.
|
|
101
|
+
export const MASS_ASSIGN_BLOCKED = new Set([
|
|
102
|
+
"role", "roles", "isadmin", "admin", "isadministrator",
|
|
103
|
+
"verified", "isverified", "permissions", "scopes",
|
|
104
|
+
"__proto__", "constructor", "prototype",
|
|
105
|
+
]);
|
|
106
|
+
function writableFields(fields) {
|
|
107
|
+
return fields.filter((f) => !MASS_ASSIGN_BLOCKED.has(String(f).toLowerCase()));
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
export function buildInsertCode(pascalName, fields, dataSource) {
|
|
111
|
+
fields = writableFields(fields);
|
|
80
112
|
const assignments = fields
|
|
81
113
|
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
82
114
|
.join(",\n");
|
|
@@ -111,8 +143,15 @@ export function buildReadCode(pascalName) {
|
|
|
111
143
|
return `
|
|
112
144
|
router.get('/', async (req, res) => {
|
|
113
145
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
// Paginate to avoid returning an unbounded collection. ?page & ?limit (max 100).
|
|
147
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
148
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
|
|
149
|
+
const skip = (page - 1) * limit;
|
|
150
|
+
const [documents, total] = await Promise.all([
|
|
151
|
+
${pascalName}.find().select('-password').skip(skip).limit(limit),
|
|
152
|
+
${pascalName}.countDocuments(),
|
|
153
|
+
]);
|
|
154
|
+
res.json({ data: documents, page, limit, total, totalPages: Math.ceil(total / limit) });
|
|
116
155
|
} catch (err) {
|
|
117
156
|
console.error(err);
|
|
118
157
|
res.status(500).json({ message: 'Server error' });
|
|
@@ -121,7 +160,8 @@ router.get('/', async (req, res) => {
|
|
|
121
160
|
|
|
122
161
|
router.get('/:id', async (req, res) => {
|
|
123
162
|
try {
|
|
124
|
-
|
|
163
|
+
// .select('-password') never leaks credential hashes even if the model has one.
|
|
164
|
+
const document = await ${pascalName}.findById(req.params.id).select('-password');
|
|
125
165
|
if (!document) {
|
|
126
166
|
return res.status(404).json({ message: 'Document not found' });
|
|
127
167
|
}
|
|
@@ -134,6 +174,7 @@ router.get('/:id', async (req, res) => {
|
|
|
134
174
|
}
|
|
135
175
|
|
|
136
176
|
export function buildUpdateCode(pascalName, fields, dataSource) {
|
|
177
|
+
fields = writableFields(fields);
|
|
137
178
|
const assignments = fields
|
|
138
179
|
.map((f) => ` ${f}: ${dataSource}.${f}`)
|
|
139
180
|
.join(",\n");
|
|
@@ -201,6 +242,10 @@ export function generateMongooseModel(pascalName, fields, indexes = []) {
|
|
|
201
242
|
if (f.index) parts.push("index: true");
|
|
202
243
|
if (f.default !== undefined && f.default !== "")
|
|
203
244
|
parts.push(`default: ${f.default}`);
|
|
245
|
+
// Never return credential fields by default — read queries must opt in with
|
|
246
|
+
// .select('+password'). Prevents accidental hash leaks through find()/findById.
|
|
247
|
+
if (/^password$/i.test(f.name) && f.select === undefined)
|
|
248
|
+
parts.push("select: false");
|
|
204
249
|
return ` ${f.name}: { ${parts.join(", ")} }`;
|
|
205
250
|
});
|
|
206
251
|
|
|
@@ -247,9 +292,14 @@ export function notFound(req, res, next) {
|
|
|
247
292
|
export function errorHandler(err, req, res, next) {
|
|
248
293
|
const status = err.status || err.statusCode || 500;
|
|
249
294
|
console.error(err);
|
|
295
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
296
|
+
// In production, only surface messages for client (4xx) errors or errors
|
|
297
|
+
// explicitly marked safe (err.expose). Never leak raw 5xx internals — a Mongo
|
|
298
|
+
// E11000 or driver message discloses schema/indexes and enables enumeration.
|
|
299
|
+
const showMessage = !isProd || err.expose === true || (status >= 400 && status < 500);
|
|
250
300
|
res.status(status).json({
|
|
251
|
-
message: err.message || 'Server error',
|
|
252
|
-
...(
|
|
301
|
+
message: showMessage ? (err.message || 'Server error') : 'Server error',
|
|
302
|
+
...(isProd ? {} : { stack: err.stack }),
|
|
253
303
|
});
|
|
254
304
|
}
|
|
255
305
|
`;
|
|
@@ -285,7 +335,12 @@ const auth = (req, res, next) => {
|
|
|
285
335
|
return res.status(401).json({ message: 'No token, authorization denied' });
|
|
286
336
|
}
|
|
287
337
|
try {
|
|
288
|
-
|
|
338
|
+
// Pin the algorithm so a token can't be forged via alg-confusion (e.g. an
|
|
339
|
+
// attacker signing HS256 with a public key if RS256 is ever introduced).
|
|
340
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
341
|
+
if (decoded.type && decoded.type !== 'access') {
|
|
342
|
+
return res.status(401).json({ message: 'Token is not valid' });
|
|
343
|
+
}
|
|
289
344
|
req.user = decoded;
|
|
290
345
|
next();
|
|
291
346
|
} catch (err) {
|
|
@@ -363,6 +418,56 @@ export default ${camel}Schema;
|
|
|
363
418
|
`;
|
|
364
419
|
}
|
|
365
420
|
|
|
421
|
+
// Parse a Zod validator's source into a { fieldName: isRequired } map for the
|
|
422
|
+
// top-level fields of its `z.object({ ... })`. A field is "required" for the API
|
|
423
|
+
// when its Zod chain has no `.optional()`/`.nullish()` — i.e. the request is
|
|
424
|
+
// rejected without it. This is the right source of truth for OpenAPI `required`,
|
|
425
|
+
// because the validator (not the Mongoose model) is what actually gates a request:
|
|
426
|
+
// a field can be optional in the DB schema yet required by the API, or vice versa.
|
|
427
|
+
// Returns null if no `z.object({...})` is found (caller should fall back to the model).
|
|
428
|
+
export function requiredFieldsFromZod(src) {
|
|
429
|
+
if (!src || typeof src !== "string") return null;
|
|
430
|
+
const objIdx = src.indexOf("z.object(");
|
|
431
|
+
if (objIdx === -1) return null;
|
|
432
|
+
const braceStart = src.indexOf("{", objIdx);
|
|
433
|
+
if (braceStart === -1) return null;
|
|
434
|
+
// Walk balanced brackets so nested objects/arrays/calls don't end the block early.
|
|
435
|
+
let depth = 0;
|
|
436
|
+
let end = -1;
|
|
437
|
+
for (let i = braceStart; i < src.length; i++) {
|
|
438
|
+
const c = src[i];
|
|
439
|
+
if (c === "{" || c === "(" || c === "[") depth++;
|
|
440
|
+
else if (c === "}" || c === ")" || c === "]") {
|
|
441
|
+
depth--;
|
|
442
|
+
if (depth === 0 && c === "}") { end = i; break; }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (end === -1) return null;
|
|
446
|
+
const inner = src.slice(braceStart + 1, end);
|
|
447
|
+
const map = {};
|
|
448
|
+
const flush = (entry) => {
|
|
449
|
+
const colon = entry.indexOf(":");
|
|
450
|
+
if (colon === -1) return;
|
|
451
|
+
const key = entry.slice(0, colon).trim().replace(/^['"]|['"]$/g, "");
|
|
452
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(key)) return;
|
|
453
|
+
const expr = entry.slice(colon + 1);
|
|
454
|
+
// .nullable() still requires the key (value may be null), so it doesn't count.
|
|
455
|
+
map[key] = !/\.(optional|nullish)\s*\(\s*\)/.test(expr);
|
|
456
|
+
};
|
|
457
|
+
// Split top-level "key: expr" entries on depth-0 commas.
|
|
458
|
+
let d = 0;
|
|
459
|
+
let cur = "";
|
|
460
|
+
for (let i = 0; i < inner.length; i++) {
|
|
461
|
+
const c = inner[i];
|
|
462
|
+
if (c === "{" || c === "(" || c === "[") d++;
|
|
463
|
+
else if (c === "}" || c === ")" || c === "]") d--;
|
|
464
|
+
if (c === "," && d === 0) { flush(cur); cur = ""; }
|
|
465
|
+
else cur += c;
|
|
466
|
+
}
|
|
467
|
+
if (cur.trim()) flush(cur);
|
|
468
|
+
return Object.keys(map).length ? map : null;
|
|
469
|
+
}
|
|
470
|
+
|
|
366
471
|
// Wire `validate(<model>Schema)` into the write endpoints of a route file, and add
|
|
367
472
|
// the two needed imports. POST/PUT use the full schema; PATCH uses
|
|
368
473
|
// `<schema>.partial()` so partial updates aren't forced to send every required
|
|
@@ -446,13 +551,31 @@ app.use(helmet({
|
|
|
446
551
|
imports.push("import rateLimit from 'express-rate-limit';");
|
|
447
552
|
beforeRoutesParts.push(
|
|
448
553
|
`// ── Rate limiting ──
|
|
554
|
+
// Generous global cap — high enough that real frontends (which fire many calls
|
|
555
|
+
// per page) never notice, low enough to stop scraping/abuse at scale.
|
|
556
|
+
// Window + cap are read from .env (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX) so the
|
|
557
|
+
// dashboard's Security page can tune them; fall back to 15 min / 500 requests.
|
|
449
558
|
const apiLimiter = rateLimit({
|
|
559
|
+
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
560
|
+
max: Number(process.env.RATE_LIMIT_MAX) || 500,
|
|
561
|
+
standardHeaders: true,
|
|
562
|
+
legacyHeaders: false,
|
|
563
|
+
});
|
|
564
|
+
// Strict cap for authentication routes — defends against password brute-forcing.
|
|
565
|
+
// Point this at your real auth routes (login / signup / password-reset).
|
|
566
|
+
const authLimiter = rateLimit({
|
|
450
567
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
451
|
-
max:
|
|
568
|
+
max: 10, // 10 attempts per IP per window
|
|
452
569
|
standardHeaders: true,
|
|
453
570
|
legacyHeaders: false,
|
|
454
571
|
});
|
|
455
|
-
|
|
572
|
+
// Throttle everywhere EXCEPT explicit local development. Defaulting to on means a
|
|
573
|
+
// deploy that forgets to set NODE_ENV=production is still protected (an unset
|
|
574
|
+
// NODE_ENV must never mean "no rate limiting" — that was the /_dev breach posture).
|
|
575
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
576
|
+
app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
|
|
577
|
+
app.use(apiLimiter); // generous global limiter for everything else
|
|
578
|
+
}`
|
|
456
579
|
);
|
|
457
580
|
}
|
|
458
581
|
if (has("errorHandler")) {
|
|
@@ -729,6 +852,8 @@ export const PACKAGE_VERSIONS = {
|
|
|
729
852
|
"@serialport/parser-readline": "^12.0.0",
|
|
730
853
|
mongoose: "^8.8.0",
|
|
731
854
|
multer: "^1.4.5-lts.1",
|
|
855
|
+
nodemailer: "^6.9.0",
|
|
856
|
+
resend: "^4.0.0",
|
|
732
857
|
};
|
|
733
858
|
|
|
734
859
|
// Add packages to package.json (dependencies, or devDependencies when dev:true).
|
|
@@ -780,6 +905,30 @@ export function extractRequestFields(handlerBody, source = "body") {
|
|
|
780
905
|
return names;
|
|
781
906
|
}
|
|
782
907
|
|
|
908
|
+
// Detect multer upload fields in a route handler so the OpenAPI builder can mark
|
|
909
|
+
// the body as multipart/form-data with the right file fields. Handles:
|
|
910
|
+
// upload.single('avatar') -> [{name:'avatar'}]
|
|
911
|
+
// upload.array('photos', 5) -> [{name:'photos'}]
|
|
912
|
+
// upload.fields([{ name: 'a' }, { name: 'b' }]) -> [{name:'a'},{name:'b'}]
|
|
913
|
+
export function extractFileFields(handlerBody) {
|
|
914
|
+
const body = handlerBody || "";
|
|
915
|
+
const out = [];
|
|
916
|
+
const seen = new Set();
|
|
917
|
+
const add = (name) => { if (name && !seen.has(name)) { seen.add(name); out.push({ name }); } };
|
|
918
|
+
(body.match(/upload\.(?:single|array)\(\s*['"]([^'"]+)['"]/g) || []).forEach((s) => {
|
|
919
|
+
const m = s.match(/['"]([^'"]+)['"]/);
|
|
920
|
+
if (m) add(m[1]);
|
|
921
|
+
});
|
|
922
|
+
const fieldsCall = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]/);
|
|
923
|
+
if (fieldsCall) {
|
|
924
|
+
(fieldsCall[1].match(/name\s*:\s*['"]([^'"]+)['"]/g) || []).forEach((n) => {
|
|
925
|
+
const m = n.match(/['"]([^'"]+)['"]/);
|
|
926
|
+
if (m) add(m[1]);
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return out;
|
|
930
|
+
}
|
|
931
|
+
|
|
783
932
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
784
933
|
// API Documentation — OpenAPI 3 spec + Swagger UI (served at /docs).
|
|
785
934
|
// Reuses extractRequestFields output (endpoints) and model field metadata.
|
|
@@ -825,7 +974,9 @@ export function buildOpenApiSpec({ title = "API", version = "1.0.0", endpoints =
|
|
|
825
974
|
const properties = {};
|
|
826
975
|
const required = [];
|
|
827
976
|
bodyFields.forEach((f) => { properties[f.name] = openApiType(f.type); if (f.required) required.push(f.name); });
|
|
828
|
-
|
|
977
|
+
// Uploaded files are required by default (an upload endpoint exists to receive
|
|
978
|
+
// the file). A file field can opt out with required:false.
|
|
979
|
+
fileFields.forEach((f) => { properties[f.name] = { type: "string", format: "binary" }; if (f.required !== false) required.push(f.name); });
|
|
829
980
|
const schema = { type: "object", properties };
|
|
830
981
|
if (required.length) schema.required = required;
|
|
831
982
|
const mime = fileFields.length ? "multipart/form-data" : "application/json";
|
|
@@ -912,21 +1063,19 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
912
1063
|
.req { color:#ef4444; font-size:11px; font-weight:700; }
|
|
913
1064
|
.opt { color:var(--faint); font-size:11px; }
|
|
914
1065
|
.resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }
|
|
915
|
-
|
|
916
|
-
|
|
1066
|
+
.tabs { display:flex; gap:4px; flex-wrap:wrap; }
|
|
1067
|
+
.tab { background:var(--input); color:var(--muted); border:1px solid var(--border); border-bottom:none;
|
|
1068
|
+
border-radius:8px 8px 0 0; padding:6px 13px; font-size:12px; cursor:pointer; }
|
|
1069
|
+
.tab:hover { color:var(--text); }
|
|
1070
|
+
.tab.active { background:#0a0c12; color:var(--accent-light); }
|
|
1071
|
+
.code-wrap { position:relative; max-width:560px; }
|
|
1072
|
+
pre.code { background:#0a0c12; border:1px solid var(--border); border-radius:0 8px 8px 8px; padding:14px;
|
|
1073
|
+
font-size:12.5px; color:#d5d8e3; overflow:auto; white-space:pre; margin:0;
|
|
917
1074
|
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; }
|
|
1075
|
+
pre.code.hidden { display:none; }
|
|
1076
|
+
.copy-btn { position:absolute; top:8px; right:8px; background:var(--card); color:var(--muted);
|
|
1077
|
+
border:1px solid var(--border); border-radius:6px; padding:4px 10px; font-size:11px; cursor:pointer; z-index:2; }
|
|
1078
|
+
.copy-btn:hover { color:var(--text); }
|
|
930
1079
|
::-webkit-scrollbar { width:9px; height:9px; }
|
|
931
1080
|
::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }
|
|
932
1081
|
</style>
|
|
@@ -957,6 +1106,90 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
957
1106
|
function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}
|
|
958
1107
|
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
1108
|
|
|
1109
|
+
// ── Client code samples (generated from the spec — no AI, no network) ──
|
|
1110
|
+
function sampleUrl(e){return (location.origin||'')+e.path;}
|
|
1111
|
+
function bodyForSample(e){var b=bodyOf(e.op);if(b&&['post','put','patch'].indexOf(e.method)>=0)return b;return null;}
|
|
1112
|
+
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);}
|
|
1113
|
+
function isMultipart(b){return !!(b&&b.mime==='multipart/form-data');}
|
|
1114
|
+
// Body fields with a flag for binary (file) fields, used to build multipart samples.
|
|
1115
|
+
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')};});}
|
|
1116
|
+
// FormData lines for a multipart body: files become a File object, others a string.
|
|
1117
|
+
function formLines(b){
|
|
1118
|
+
var out=["const form = new FormData();"];
|
|
1119
|
+
fieldList(b).forEach(function(f){
|
|
1120
|
+
out.push("form.append('"+f.name+"', "+(f.file?"fileInput.files[0]":"''")+");"+(f.file?" // a File from an <input type=file>":""));
|
|
1121
|
+
});
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
function curlSample(e){
|
|
1125
|
+
var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];
|
|
1126
|
+
L.push("curl -X "+e.method.toUpperCase()+" '"+sampleUrl(e)+"'");
|
|
1127
|
+
if(e.op.security)L.push("-H 'Authorization: Bearer <token>'");
|
|
1128
|
+
if(b&&isMultipart(b)){
|
|
1129
|
+
// curl sets the multipart Content-Type (with boundary) itself; -F per field.
|
|
1130
|
+
fieldList(b).forEach(function(f){L.push("-F '"+f.name+"="+(f.file?"@/path/to/file":"")+"'");});
|
|
1131
|
+
} else if(b){
|
|
1132
|
+
L.push("-H 'Content-Type: application/json'");
|
|
1133
|
+
var c;try{c=JSON.stringify(JSON.parse(bodySkeleton(b.schema)));}catch(x){c=bodySkeleton(b.schema);}
|
|
1134
|
+
L.push("-d '"+c+"'");
|
|
1135
|
+
}
|
|
1136
|
+
return L.join(" "+BS+NL+" ");
|
|
1137
|
+
}
|
|
1138
|
+
function fetchSample(e){
|
|
1139
|
+
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
|
|
1140
|
+
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
|
|
1141
|
+
L.push("const res = await fetch('"+sampleUrl(e)+"', {");
|
|
1142
|
+
L.push(" method: '"+e.method.toUpperCase()+"',");
|
|
1143
|
+
var h=[];
|
|
1144
|
+
// For multipart, never set Content-Type by hand — the browser adds the boundary.
|
|
1145
|
+
if(b&&!mp)h.push(" 'Content-Type': 'application/json'");
|
|
1146
|
+
if(e.op.security)h.push(" 'Authorization': 'Bearer <token>'");
|
|
1147
|
+
if(h.length){L.push(" headers: {");L.push(h.join(","+NL));L.push(" },");}
|
|
1148
|
+
if(mp)L.push(" body: form");
|
|
1149
|
+
else if(b)L.push(" body: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+")");
|
|
1150
|
+
L.push("});");
|
|
1151
|
+
L.push("const data = await res.json();");
|
|
1152
|
+
L.push("console.log(data);");
|
|
1153
|
+
return L.join(NL);
|
|
1154
|
+
}
|
|
1155
|
+
function axiosSample(e){
|
|
1156
|
+
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
|
|
1157
|
+
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
|
|
1158
|
+
L.push("const res = await axios({");
|
|
1159
|
+
L.push(" method: '"+e.method.toLowerCase()+"',");
|
|
1160
|
+
L.push(" url: '"+sampleUrl(e)+"',");
|
|
1161
|
+
if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
|
|
1162
|
+
if(mp)L.push(" data: form");
|
|
1163
|
+
else if(b)L.push(" data: "+indentBody(bodySkeleton(b.schema)," "));
|
|
1164
|
+
L.push("});");
|
|
1165
|
+
L.push("console.log(res.data);");
|
|
1166
|
+
return L.join(NL);
|
|
1167
|
+
}
|
|
1168
|
+
function jquerySample(e){
|
|
1169
|
+
var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];
|
|
1170
|
+
if(mp)formLines(b).forEach(function(ln){L.push(ln);});
|
|
1171
|
+
L.push("$.ajax({");
|
|
1172
|
+
L.push(" url: '"+sampleUrl(e)+"',");
|
|
1173
|
+
L.push(" method: '"+e.method.toUpperCase()+"',");
|
|
1174
|
+
if(mp){L.push(" data: form,");L.push(" processData: false,");L.push(" contentType: false,");}
|
|
1175
|
+
else if(b)L.push(" contentType: 'application/json',");
|
|
1176
|
+
if(e.op.security)L.push(" headers: { 'Authorization': 'Bearer <token>' },");
|
|
1177
|
+
if(b&&!mp)L.push(" data: JSON.stringify("+indentBody(bodySkeleton(b.schema)," ")+"),");
|
|
1178
|
+
L.push(" success: function(data) {");
|
|
1179
|
+
L.push(" console.log(data);");
|
|
1180
|
+
L.push(" }");
|
|
1181
|
+
L.push("});");
|
|
1182
|
+
return L.join(NL);
|
|
1183
|
+
}
|
|
1184
|
+
function buildSamples(e){
|
|
1185
|
+
return [
|
|
1186
|
+
{lang:'curl',label:'cURL',code:curlSample(e)},
|
|
1187
|
+
{lang:'fetch',label:'JavaScript (fetch)',code:fetchSample(e)},
|
|
1188
|
+
{lang:'axios',label:'axios',code:axiosSample(e)},
|
|
1189
|
+
{lang:'jquery',label:'jQuery',code:jquerySample(e)}
|
|
1190
|
+
];
|
|
1191
|
+
}
|
|
1192
|
+
|
|
960
1193
|
function detailsHtml(e,id){
|
|
961
1194
|
var html='';
|
|
962
1195
|
var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);
|
|
@@ -978,44 +1211,34 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
978
1211
|
Object.keys(resp).forEach(function(code){
|
|
979
1212
|
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
1213
|
});
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
html+='<
|
|
987
|
-
html+='<
|
|
988
|
-
html+='
|
|
1214
|
+
var samples=buildSamples(e);
|
|
1215
|
+
html+='<div class="sec">Code samples</div>';
|
|
1216
|
+
html+='<div class="tabs" id="'+id+'_tabs">';
|
|
1217
|
+
samples.forEach(function(s,i){html+='<button class="tab'+(i===0?' active':'')+'" data-lang="'+s.lang+'">'+esc(s.label)+'</button>';});
|
|
1218
|
+
html+='</div>';
|
|
1219
|
+
html+='<div class="code-wrap"><button class="copy-btn">Copy</button>';
|
|
1220
|
+
samples.forEach(function(s,i){html+='<pre class="code'+(i===0?'':' hidden')+'" data-lang="'+s.lang+'">'+esc(s.code)+'</pre>';});
|
|
1221
|
+
html+='</div>';
|
|
989
1222
|
return html;
|
|
990
1223
|
}
|
|
991
1224
|
|
|
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;});
|
|
1225
|
+
function wireSamples(id,root){
|
|
1226
|
+
var tabs=root.querySelector('#'+id+'_tabs');if(!tabs)return;
|
|
1227
|
+
var btns=tabs.querySelectorAll('.tab');
|
|
1228
|
+
btns.forEach(function(b){
|
|
1229
|
+
b.addEventListener('click',function(){
|
|
1230
|
+
var lang=b.getAttribute('data-lang');
|
|
1231
|
+
btns.forEach(function(x){x.classList.remove('active');});b.classList.add('active');
|
|
1232
|
+
root.querySelectorAll('pre.code[data-lang]').forEach(function(p){p.classList.toggle('hidden',p.getAttribute('data-lang')!==lang);});
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
var copy=root.querySelector('.copy-btn');
|
|
1236
|
+
if(copy)copy.addEventListener('click',function(){
|
|
1237
|
+
var active=root.querySelector('pre.code[data-lang]:not(.hidden)');if(!active)return;
|
|
1238
|
+
var txt=active.textContent;
|
|
1239
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt);}
|
|
1240
|
+
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);}
|
|
1241
|
+
copy.textContent='Copied';setTimeout(function(){copy.textContent='Copy';},1200);
|
|
1019
1242
|
});
|
|
1020
1243
|
}
|
|
1021
1244
|
|
|
@@ -1030,7 +1253,7 @@ export function buildDocsHtml(specUrl = "openapi.json") {
|
|
|
1030
1253
|
html+='<div class="detail-sum">'+esc(e.op.summary||'')+lock+'</div>';
|
|
1031
1254
|
html+=detailsHtml(e,id);
|
|
1032
1255
|
main.innerHTML=html;main.scrollTop=0;
|
|
1033
|
-
|
|
1256
|
+
wireSamples(id,main);
|
|
1034
1257
|
}
|
|
1035
1258
|
|
|
1036
1259
|
function render(spec){
|
|
@@ -1128,37 +1351,70 @@ const DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
1128
1351
|
const SPEC = path.join(DIR, 'openapi.json');
|
|
1129
1352
|
const AUTH = path.join(DIR, 'docs-auth.json');
|
|
1130
1353
|
|
|
1131
|
-
// Optional password protection. If src/docs-auth.json holds
|
|
1354
|
+
// Optional password protection. If src/docs-auth.json holds { hash, secret }, the
|
|
1132
1355
|
// whole /docs surface requires a single password (NO username) via a small login
|
|
1133
|
-
// page + cookie. Set/remove the password from the dashboard
|
|
1134
|
-
// (mtime-cached), so changes apply without a restart.
|
|
1135
|
-
let _authCache = { mtimeMs: -1, hash: null };
|
|
1136
|
-
function
|
|
1356
|
+
// page + a signed session cookie. Set/remove the password from the dashboard
|
|
1357
|
+
// (API Docs). Read live (mtime-cached), so changes apply without a restart.
|
|
1358
|
+
let _authCache = { mtimeMs: -1, hash: null, secret: null };
|
|
1359
|
+
function docsAuth() {
|
|
1137
1360
|
try {
|
|
1138
1361
|
const { mtimeMs } = fs.statSync(AUTH);
|
|
1139
1362
|
if (mtimeMs !== _authCache.mtimeMs) {
|
|
1140
1363
|
const data = JSON.parse(fs.readFileSync(AUTH, 'utf8'));
|
|
1141
|
-
_authCache = { mtimeMs, hash: (data && data.hash) || null };
|
|
1364
|
+
_authCache = { mtimeMs, hash: (data && data.hash) || null, secret: (data && data.secret) || null };
|
|
1142
1365
|
}
|
|
1143
|
-
} catch { _authCache = { mtimeMs: -1, hash: null }; }
|
|
1144
|
-
return _authCache
|
|
1366
|
+
} catch { _authCache = { mtimeMs: -1, hash: null, secret: null }; }
|
|
1367
|
+
return _authCache;
|
|
1145
1368
|
}
|
|
1369
|
+
// Stable server secret for signing session cookies. Prefers the persisted secret;
|
|
1370
|
+
// falls back to an ephemeral random one (legacy files with no secret) so a leaked
|
|
1371
|
+
// docs-auth.json can never be forged into a valid session.
|
|
1372
|
+
const EPHEMERAL_SECRET = crypto.randomBytes(32).toString('hex');
|
|
1373
|
+
function sessionSecret() { return docsAuth().secret || EPHEMERAL_SECRET; }
|
|
1146
1374
|
function safeEq(a, b) {
|
|
1147
1375
|
try { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); } catch { return false; }
|
|
1148
1376
|
}
|
|
1377
|
+
// Verify a submitted password against the stored hash: salted scrypt
|
|
1378
|
+
// (scrypt$salt$hash) or legacy unsalted sha256 (back-compat).
|
|
1379
|
+
function verifyPassword(candidate, hash) {
|
|
1380
|
+
if (!candidate || !hash) return false;
|
|
1381
|
+
try {
|
|
1382
|
+
if (hash.startsWith('scrypt$')) {
|
|
1383
|
+
const [, saltHex, hashHex] = hash.split('$');
|
|
1384
|
+
const expected = Buffer.from(hashHex, 'hex');
|
|
1385
|
+
const actual = crypto.scryptSync(String(candidate), Buffer.from(saltHex, 'hex'), expected.length);
|
|
1386
|
+
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
|
1387
|
+
}
|
|
1388
|
+
return safeEq(crypto.createHash('sha256').update(String(candidate)).digest('hex'), hash);
|
|
1389
|
+
} catch { return false; }
|
|
1390
|
+
}
|
|
1391
|
+
// Session cookie = base64url(payload).hmac. The stored hash is NEVER sent to the
|
|
1392
|
+
// client, so a leaked docs-auth.json can't be replayed as a session.
|
|
1393
|
+
function signSession() {
|
|
1394
|
+
const payload = Buffer.from(JSON.stringify({ exp: Date.now() + 86400000 })).toString('base64url');
|
|
1395
|
+
const sig = crypto.createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
|
|
1396
|
+
return payload + '.' + sig;
|
|
1397
|
+
}
|
|
1398
|
+
function validSession(token) {
|
|
1399
|
+
if (!token || typeof token !== 'string' || token.indexOf('.') === -1) return false;
|
|
1400
|
+
const [payload, sig] = token.split('.');
|
|
1401
|
+
const expect = crypto.createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
|
|
1402
|
+
if (!safeEq(sig, expect)) return false;
|
|
1403
|
+
try { const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return typeof exp === 'number' && exp > Date.now(); } catch { return false; }
|
|
1404
|
+
}
|
|
1149
1405
|
function cookieToken(req) {
|
|
1150
1406
|
const m = (req.headers.cookie || '').match(/(?:^|; )docs_auth=([^;]+)/);
|
|
1151
|
-
return m ? m[1] : null;
|
|
1407
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
1152
1408
|
}
|
|
1153
1409
|
const LOGIN_HTML = ${JSON.stringify(buildDocsLoginHtml())};
|
|
1154
1410
|
|
|
1155
1411
|
// Password submit — reachable without auth.
|
|
1156
1412
|
router.post('/login', (req, res) => {
|
|
1157
|
-
const
|
|
1158
|
-
if (!
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
res.setHeader('Set-Cookie', 'docs_auth=' +
|
|
1413
|
+
const { hash } = docsAuth();
|
|
1414
|
+
if (!hash) return res.redirect('/docs');
|
|
1415
|
+
if (verifyPassword(String((req.body && req.body.password) || ''), hash)) {
|
|
1416
|
+
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
|
1417
|
+
res.setHeader('Set-Cookie', 'docs_auth=' + signSession() + '; Path=/docs; HttpOnly; SameSite=Lax; Max-Age=86400' + secure);
|
|
1162
1418
|
return res.redirect('/docs');
|
|
1163
1419
|
}
|
|
1164
1420
|
return res.status(401).type('html').send(LOGIN_HTML.replace('<!--ERR-->', 'Incorrect password'));
|
|
@@ -1166,9 +1422,9 @@ router.post('/login', (req, res) => {
|
|
|
1166
1422
|
|
|
1167
1423
|
// Gate the rest of /docs behind the password (when one is set).
|
|
1168
1424
|
router.use((req, res, next) => {
|
|
1169
|
-
const
|
|
1170
|
-
if (!
|
|
1171
|
-
if (
|
|
1425
|
+
const { hash } = docsAuth();
|
|
1426
|
+
if (!hash) return next(); // no password → open
|
|
1427
|
+
if (validSession(cookieToken(req))) return next();
|
|
1172
1428
|
return res.status(401).type('html').send(LOGIN_HTML);
|
|
1173
1429
|
});
|
|
1174
1430
|
|
|
@@ -1205,29 +1461,57 @@ export default router;
|
|
|
1205
1461
|
}
|
|
1206
1462
|
|
|
1207
1463
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1208
|
-
// Email
|
|
1464
|
+
// Email — two backends: IntraApp(Postmaster) via the Resend API, or any SMTP
|
|
1465
|
+
// server via nodemailer. `type` selects the code path; `apiKeyOnly` tells the
|
|
1466
|
+
// dashboard to show a single API-key field (no host/port/user).
|
|
1209
1467
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1210
1468
|
export const MAIL_PROVIDERS = {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
mailgun: { label: "Mailgun", host: "smtp.mailgun.org", port: 587, secure: false, hint: "Postmaster user + SMTP password" },
|
|
1214
|
-
gmail: { label: "Gmail", host: "smtp.gmail.com", port: 465, secure: true, hint: "Use an App Password, not your login password" },
|
|
1215
|
-
mailtrap: { label: "Mailtrap (test)", host: "sandbox.smtp.mailtrap.io", port: 587, secure: false, hint: "Sandbox inbox for testing" },
|
|
1216
|
-
smtp: { label: "Custom SMTP", host: "", port: 587, secure: false, hint: "Any SMTP server" },
|
|
1469
|
+
resend: { label: "IntraApp(Postmaster)", type: "resend", apiKeyOnly: true, hint: "Paste your API key (re_...). Sends via the Resend API — no SMTP host or port needed." },
|
|
1470
|
+
smtp: { label: "Custom SMTP", type: "smtp", host: "", port: 587, secure: false, hint: "Any SMTP server. Tick TLS for port 465, otherwise 587." },
|
|
1217
1471
|
};
|
|
1218
1472
|
|
|
1219
|
-
//
|
|
1473
|
+
// The 4brains.in email domain is official and gated: it may only be configured as
|
|
1474
|
+
// a sender with the shared authorization password, and every configuration is
|
|
1475
|
+
// reported to the address below. These are shipped into generated apps on purpose.
|
|
1476
|
+
export const OFFICIAL_MAIL_DOMAIN = "4brains.in";
|
|
1477
|
+
export const OFFICIAL_MAIL_DOMAIN_PASSWORD = "99321239";
|
|
1478
|
+
export const OFFICIAL_MAIL_NOTIFY = "pawan@4brains.in";
|
|
1479
|
+
export function isOfficialMailDomain(email) {
|
|
1480
|
+
return /@4brains\.in$/i.test(String(email || "").trim());
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// src/services/mailer.js — unified mailer with two backends selected by
|
|
1484
|
+
// MAIL_PROVIDER in .env. The public API (sendMail / emailTemplate / verifyMailer)
|
|
1485
|
+
// is identical regardless of backend, so calling code never changes.
|
|
1220
1486
|
export function buildMailerService() {
|
|
1221
|
-
return
|
|
1222
|
-
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1487
|
+
return `// Unified mailer. Backend is chosen by MAIL_PROVIDER in .env:
|
|
1488
|
+
// - "resend": IntraApp(Postmaster), via the Resend API (RESEND_API_KEY, MAIL_FROM)
|
|
1489
|
+
// - "smtp": any SMTP server, via nodemailer (SMTP_* vars)
|
|
1490
|
+
// Only the selected backend's package is imported (lazily), so you don't need
|
|
1491
|
+
// both installed. sendMail({ to, subject, html }) works the same either way.
|
|
1492
|
+
const PROVIDER = (process.env.MAIL_PROVIDER || 'smtp').toLowerCase();
|
|
1493
|
+
|
|
1494
|
+
let _clientPromise = null;
|
|
1495
|
+
function getClient() {
|
|
1496
|
+
if (_clientPromise) return _clientPromise;
|
|
1497
|
+
_clientPromise = (async () => {
|
|
1498
|
+
if (PROVIDER === 'resend') {
|
|
1499
|
+
const { Resend } = await import('resend');
|
|
1500
|
+
return { type: 'resend', resend: new Resend(process.env.RESEND_API_KEY) };
|
|
1501
|
+
}
|
|
1502
|
+
const nodemailer = (await import('nodemailer')).default;
|
|
1503
|
+
return {
|
|
1504
|
+
type: 'smtp',
|
|
1505
|
+
transporter: nodemailer.createTransport({
|
|
1506
|
+
host: process.env.SMTP_HOST,
|
|
1507
|
+
port: Number(process.env.SMTP_PORT) || 587,
|
|
1508
|
+
secure: process.env.SMTP_SECURE === 'true', // true for port 465 (TLS)
|
|
1509
|
+
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
1510
|
+
}),
|
|
1511
|
+
};
|
|
1512
|
+
})();
|
|
1513
|
+
return _clientPromise;
|
|
1514
|
+
}
|
|
1231
1515
|
|
|
1232
1516
|
// Wrap content in a simple, responsive HTML email.
|
|
1233
1517
|
export function emailTemplate({ title = '', body = '' } = {}) {
|
|
@@ -1248,23 +1532,43 @@ export function emailTemplate({ title = '', body = '' } = {}) {
|
|
|
1248
1532
|
}
|
|
1249
1533
|
|
|
1250
1534
|
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
1251
|
-
|
|
1535
|
+
// attachments: [{ filename, path }] or [{ filename, content }].
|
|
1536
|
+
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
|
|
1252
1537
|
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
1253
|
-
|
|
1538
|
+
// 4brains.in is an official domain. Sending from it is only allowed once it has
|
|
1539
|
+
// been configured through the 4bnode dashboard/CLI with the authorization
|
|
1540
|
+
// password (which sets OFFICIAL_MAIL_AUTHORIZED). Direct/code use is blocked.
|
|
1541
|
+
const effectiveFrom = from || (PROVIDER === 'resend' ? process.env.MAIL_FROM : (process.env.SMTP_FROM || process.env.SMTP_USER));
|
|
1542
|
+
const touchesOfficial = /4brains\\.in/i.test(String(effectiveFrom || '')) || (PROVIDER === 'smtp' && /4brains\\.in/i.test(String(process.env.SMTP_HOST || '')));
|
|
1543
|
+
if (touchesOfficial && !process.env.OFFICIAL_MAIL_AUTHORIZED) {
|
|
1544
|
+
throw new Error('The 4brains.in domain is official and cannot be used directly — configure it via the 4bnode dashboard (Email) with the authorization password.');
|
|
1545
|
+
}
|
|
1546
|
+
const client = await getClient();
|
|
1547
|
+
if (client.type === 'resend') {
|
|
1548
|
+
const { data, error } = await client.resend.emails.send({
|
|
1549
|
+
from: from || process.env.MAIL_FROM,
|
|
1550
|
+
to, cc, bcc, replyTo, subject, html, text, attachments,
|
|
1551
|
+
});
|
|
1552
|
+
if (error) throw new Error(error.message || 'Resend send failed');
|
|
1553
|
+
return data;
|
|
1554
|
+
}
|
|
1555
|
+
return client.transporter.sendMail({
|
|
1254
1556
|
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
1255
|
-
to,
|
|
1256
|
-
subject,
|
|
1257
|
-
text,
|
|
1258
|
-
html,
|
|
1557
|
+
to, cc, bcc, replyTo, subject, text, html, attachments,
|
|
1259
1558
|
});
|
|
1260
1559
|
}
|
|
1261
1560
|
|
|
1262
|
-
// Verify the SMTP
|
|
1263
|
-
export function verifyMailer() {
|
|
1264
|
-
|
|
1561
|
+
// Verify the mailer is usable (SMTP handshake, or that the Resend key is set).
|
|
1562
|
+
export async function verifyMailer() {
|
|
1563
|
+
const client = await getClient();
|
|
1564
|
+
if (client.type === 'resend') {
|
|
1565
|
+
if (!process.env.RESEND_API_KEY) throw new Error('RESEND_API_KEY is not set.');
|
|
1566
|
+
return true;
|
|
1567
|
+
}
|
|
1568
|
+
return client.transporter.verify();
|
|
1265
1569
|
}
|
|
1266
1570
|
|
|
1267
|
-
export default { sendMail, emailTemplate, verifyMailer
|
|
1571
|
+
export default { sendMail, emailTemplate, verifyMailer };
|
|
1268
1572
|
`;
|
|
1269
1573
|
}
|
|
1270
1574
|
|