4bnode 4.1.5 → 4.1.7
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-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +191 -57
- package/lib/codegen.test.js +87 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- 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 +110 -50
- package/skeleton/.4bnode/codegen.js +191 -57
- package/skeleton/.4bnode/dev-api.js +284 -56
- package/skeleton/.env.example +17 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +27 -4
- 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 -224
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
- 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 -1440
- package/app-1/.4bnode/dev-api.js +0 -3747
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/.env.example +0 -11
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -188
- package/app-1/package.json +0 -37
- 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/apiKey.js +0 -36
- package/app-1/src/middleware/auth.js +0 -17
- 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/user.js +0 -11
- package/app-1/src/openapi.json +0 -80
- package/app-1/src/routes/upload-resume.js +0 -21
- package/app-1/src/services/mailer.js +0 -52
- package/app-1/src/validators/upload-resume.js +0 -8
|
@@ -6,6 +6,7 @@ import { promisify } from 'util';
|
|
|
6
6
|
import { pathToFileURL } from 'url';
|
|
7
7
|
const execAsync = promisify(exec);
|
|
8
8
|
import crypto from 'crypto';
|
|
9
|
+
import os from 'os';
|
|
9
10
|
// Shared, dependency-free code generation. This file is a verbatim copy of the
|
|
10
11
|
// 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen
|
|
11
12
|
// in the package, not here — it is regenerated when a project is scaffolded.
|
|
@@ -40,10 +41,37 @@ import {
|
|
|
40
41
|
buildDocsRouter,
|
|
41
42
|
MAIL_PROVIDERS,
|
|
42
43
|
buildMailerService,
|
|
44
|
+
isOfficialMailDomain,
|
|
45
|
+
OFFICIAL_MAIL_DOMAIN_PASSWORD,
|
|
46
|
+
OFFICIAL_MAIL_NOTIFY,
|
|
43
47
|
} from './codegen.js';
|
|
44
48
|
|
|
45
49
|
const router = express.Router();
|
|
46
50
|
|
|
51
|
+
// ── Path-safety helpers ──────────────────────────────────────────────────────
|
|
52
|
+
// Every :name/:id route parameter that maps to a file must pass through safeStem,
|
|
53
|
+
// so a value like "../../etc/passwd" (or its %2f-encoded form) can never escape
|
|
54
|
+
// the intended directory. sanitizeName throws on anything but a simple identifier.
|
|
55
|
+
function safeStem(name) {
|
|
56
|
+
return sanitizeName(String(name == null ? '' : name).replace(/\.js$/i, ''));
|
|
57
|
+
}
|
|
58
|
+
function escapeRegExp(s) {
|
|
59
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
}
|
|
61
|
+
// SSRF guard for the API tester. Loopback and private LAN ranges are intentionally
|
|
62
|
+
// allowed — testing your own local/LAN API is the whole point — but cloud-metadata
|
|
63
|
+
// and link-local hosts (169.254.x, fe80::) are never a legit target and are the
|
|
64
|
+
// classic SSRF pivot, so they're refused.
|
|
65
|
+
function blockedFetchTarget(rawUrl) {
|
|
66
|
+
let u;
|
|
67
|
+
try { u = new URL(rawUrl); } catch { return 'Invalid URL'; }
|
|
68
|
+
if (!/^https?:$/.test(u.protocol)) return 'Only http(s) URLs are allowed';
|
|
69
|
+
const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
70
|
+
if (host === '169.254.169.254' || host === 'metadata.google.internal' || host === 'fd00:ec2::254') return 'Blocked: cloud metadata endpoint';
|
|
71
|
+
if (/^169\.254\./.test(host) || /^fe80:/i.test(host)) return 'Blocked: link-local address';
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
47
75
|
// ── Network restriction ─────────────────────────────────────────────────────
|
|
48
76
|
// This router can write files and run shell commands — it is a remote-code-
|
|
49
77
|
// execution surface. Refuse any request that does not originate from the local
|
|
@@ -68,11 +96,35 @@ router.use((req, res, next) => {
|
|
|
68
96
|
const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
|
|
69
97
|
function isLoopbackRequest(req) {
|
|
70
98
|
const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
|
|
71
|
-
|
|
99
|
+
if (ip !== '127.0.0.1' && ip !== '::1') return false;
|
|
100
|
+
// A loopback peer that carries a forwarding header is really traffic relayed by
|
|
101
|
+
// a same-host reverse proxy — treat it as remote so a co-located proxy can't be
|
|
102
|
+
// used to reach the dashboard while appearing local.
|
|
103
|
+
if (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['forwarded']) return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
// Host/Origin allowlist — defeats DNS-rebinding and cross-site fetch to loopback.
|
|
107
|
+
// A page attacking via a rebound domain still sends that domain in Host/Origin;
|
|
108
|
+
// only genuine localhost/127.0.0.1/[::1] (optionally with a port) pass. Missing
|
|
109
|
+
// header (curl, same-origin top-level navigation) is allowed.
|
|
110
|
+
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/i;
|
|
111
|
+
function hostAllowed(value) {
|
|
112
|
+
if (!value) return true;
|
|
113
|
+
try {
|
|
114
|
+
const h = value.startsWith('http') ? new URL(value).host : value;
|
|
115
|
+
return LOCAL_HOST_RE.test(h);
|
|
116
|
+
} catch { return false; }
|
|
72
117
|
}
|
|
73
118
|
router.use((req, res, next) => {
|
|
74
|
-
if (ALLOW_REMOTE
|
|
75
|
-
|
|
119
|
+
if (ALLOW_REMOTE) return next();
|
|
120
|
+
if (!isLoopbackRequest(req)) {
|
|
121
|
+
return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
|
|
122
|
+
}
|
|
123
|
+
// Even for a genuine loopback peer, reject cross-origin / rebound Host or Origin.
|
|
124
|
+
if (!hostAllowed(req.headers.host) || !hostAllowed(req.headers.origin)) {
|
|
125
|
+
return res.status(403).json({ error: 'Invalid Host/Origin for the local dashboard.' });
|
|
126
|
+
}
|
|
127
|
+
next();
|
|
76
128
|
});
|
|
77
129
|
|
|
78
130
|
// ── Console Log Capture ─────────────────────────────────────────────────────
|
|
@@ -175,6 +227,16 @@ function recordAuthFailure(req) {
|
|
|
175
227
|
function recordAuthSuccess(req) {
|
|
176
228
|
_authFails.delete(authClientKey(req));
|
|
177
229
|
}
|
|
230
|
+
// Migrate a legacy unsalted-sha256 .passkey to salted scrypt on a successful login,
|
|
231
|
+
// so old apps stop storing a rainbow-reversible hash. No-op if already scrypt.
|
|
232
|
+
function upgradeLegacyPasskey(candidate, stored) {
|
|
233
|
+
if (!stored || stored.startsWith('scrypt$')) return;
|
|
234
|
+
try {
|
|
235
|
+
const salt = crypto.randomBytes(16);
|
|
236
|
+
const hash = 'scrypt$' + salt.toString('hex') + '$' + crypto.scryptSync(String(candidate), salt, 32).toString('hex');
|
|
237
|
+
fs.writeFileSync(path.join(__4bnodeDir, '.passkey'), hash, 'utf8');
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
178
240
|
|
|
179
241
|
// Verify passkey endpoint (no auth middleware needed here)
|
|
180
242
|
router.post('/api/auth', (req, res) => {
|
|
@@ -205,6 +267,7 @@ router.post('/api/auth', (req, res) => {
|
|
|
205
267
|
return res.status(401).json({ error: 'Invalid passkey' });
|
|
206
268
|
}
|
|
207
269
|
recordAuthSuccess(req);
|
|
270
|
+
upgradeLegacyPasskey(passkey, stored);
|
|
208
271
|
res.json({ ok: true });
|
|
209
272
|
});
|
|
210
273
|
|
|
@@ -230,6 +293,7 @@ router.use('/api', (req, res, next) => {
|
|
|
230
293
|
if (passkey) recordAuthFailure(req);
|
|
231
294
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
232
295
|
}
|
|
296
|
+
upgradeLegacyPasskey(passkey, stored);
|
|
233
297
|
next();
|
|
234
298
|
});
|
|
235
299
|
|
|
@@ -502,7 +566,7 @@ router.get('/api/models', (req, res) => {
|
|
|
502
566
|
// GET /api/models/:name
|
|
503
567
|
router.get('/api/models/:name', (req, res) => {
|
|
504
568
|
try {
|
|
505
|
-
const name = req.params.name;
|
|
569
|
+
const name = safeStem(req.params.name);
|
|
506
570
|
const { fields, indexes } = getModelSchemaDetailed(name);
|
|
507
571
|
res.json({ name, fields, indexes });
|
|
508
572
|
} catch (err) {
|
|
@@ -513,7 +577,7 @@ router.get('/api/models/:name', (req, res) => {
|
|
|
513
577
|
// GET /api/models/:name/data — fetch stored documents from MongoDB collection
|
|
514
578
|
router.get('/api/models/:name/data', async (req, res) => {
|
|
515
579
|
try {
|
|
516
|
-
const name = req.params.name;
|
|
580
|
+
const name = safeStem(req.params.name);
|
|
517
581
|
const rawLimit = parseInt(req.query.limit);
|
|
518
582
|
const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
|
|
519
583
|
const skip = parseInt(req.query.skip) || 0;
|
|
@@ -548,7 +612,7 @@ router.get('/api/models/:name/data', async (req, res) => {
|
|
|
548
612
|
// GET /api/models/:name/share — generate shareable endpoint documentation
|
|
549
613
|
router.get('/api/models/:name/share', (req, res) => {
|
|
550
614
|
try {
|
|
551
|
-
const name = req.params.name;
|
|
615
|
+
const name = safeStem(req.params.name);
|
|
552
616
|
const { fields } = getModelSchemaDetailed(name);
|
|
553
617
|
const routes = listRoutes();
|
|
554
618
|
const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
|
|
@@ -612,7 +676,7 @@ router.post('/api/models', (req, res) => {
|
|
|
612
676
|
// PUT /api/models/:name
|
|
613
677
|
router.put('/api/models/:name', (req, res) => {
|
|
614
678
|
try {
|
|
615
|
-
const name = req.params.name;
|
|
679
|
+
const name = safeStem(req.params.name);
|
|
616
680
|
const { fields, indexes } = req.body;
|
|
617
681
|
const pascalName = toPascalCase(name);
|
|
618
682
|
|
|
@@ -633,7 +697,7 @@ router.put('/api/models/:name', (req, res) => {
|
|
|
633
697
|
// DELETE /api/models/:name
|
|
634
698
|
router.delete('/api/models/:name', (req, res) => {
|
|
635
699
|
try {
|
|
636
|
-
const name = req.params.name;
|
|
700
|
+
const name = safeStem(req.params.name);
|
|
637
701
|
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
|
|
638
702
|
if (!fs.existsSync(filePath)) {
|
|
639
703
|
return res.status(404).json({ error: `Model "${name}" not found.` });
|
|
@@ -660,10 +724,10 @@ router.get('/api/routes', (req, res) => {
|
|
|
660
724
|
// GET /api/routes/:name/content
|
|
661
725
|
router.get('/api/routes/:name/content', (req, res) => {
|
|
662
726
|
try {
|
|
663
|
-
const name = req.params.name;
|
|
664
|
-
const filePath = path.join(process.cwd(), 'src', 'routes', name);
|
|
727
|
+
const name = safeStem(req.params.name);
|
|
728
|
+
const filePath = path.join(process.cwd(), 'src', 'routes', name + '.js');
|
|
665
729
|
if (!fs.existsSync(filePath)) {
|
|
666
|
-
return res.status(404).json({ error: `Route file "${name}" not found.` });
|
|
730
|
+
return res.status(404).json({ error: `Route file "${name}.js" not found.` });
|
|
667
731
|
}
|
|
668
732
|
|
|
669
733
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
@@ -676,9 +740,9 @@ router.get('/api/routes/:name/content', (req, res) => {
|
|
|
676
740
|
// DELETE /api/routes/:name
|
|
677
741
|
router.delete('/api/routes/:name', (req, res) => {
|
|
678
742
|
try {
|
|
679
|
-
const name = req.params.name;
|
|
680
|
-
const routeFile = name
|
|
681
|
-
const safeName =
|
|
743
|
+
const name = safeStem(req.params.name);
|
|
744
|
+
const routeFile = name + '.js';
|
|
745
|
+
const safeName = name;
|
|
682
746
|
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
683
747
|
|
|
684
748
|
if (!fs.existsSync(filePath)) {
|
|
@@ -710,9 +774,9 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
710
774
|
if (fs.existsSync(indexPath)) {
|
|
711
775
|
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
712
776
|
// Remove import line
|
|
713
|
-
indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${safeName}\\.js['\"];?\\n?`, 'g'), '');
|
|
777
|
+
indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${escapeRegExp(safeName)}\\.js['\"];?\\n?`, 'g'), '');
|
|
714
778
|
// Remove app.use registration line
|
|
715
|
-
indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${safeName}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
|
|
779
|
+
indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${escapeRegExp(safeName)}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
|
|
716
780
|
fs.writeFileSync(indexPath, indexContent, 'utf8');
|
|
717
781
|
}
|
|
718
782
|
} catch (err) {
|
|
@@ -725,7 +789,7 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
725
789
|
// ---------------------------------------------------------------------------
|
|
726
790
|
router.post('/api/routes/:name/add-method', async (req, res) => {
|
|
727
791
|
try {
|
|
728
|
-
const name = req.params.name
|
|
792
|
+
const name = safeStem(req.params.name);
|
|
729
793
|
const routeFile = name + '.js';
|
|
730
794
|
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
731
795
|
if (!fs.existsSync(filePath)) {
|
|
@@ -1259,69 +1323,220 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1259
1323
|
// ---------------------------------------------------------------------------
|
|
1260
1324
|
const mailerFilePath = () => path.join(process.cwd(), 'src', 'services', 'mailer.js');
|
|
1261
1325
|
|
|
1262
|
-
//
|
|
1326
|
+
// Constant-time check of the shared 4brains.in authorization password.
|
|
1327
|
+
function officialDomainAuthorized(supplied) {
|
|
1328
|
+
const a = Buffer.from(String(supplied || ''));
|
|
1329
|
+
const b = Buffer.from(OFFICIAL_MAIL_DOMAIN_PASSWORD);
|
|
1330
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Report that a 4brains.in sender was configured — who (machine/app) and which
|
|
1334
|
+
// email id — to the official notify address, using the just-saved provider.
|
|
1335
|
+
async function notifyOfficialDomain(envPath, provider, fromAddr) {
|
|
1336
|
+
let appName = 'unknown';
|
|
1337
|
+
try { appName = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).name || appName; } catch {}
|
|
1338
|
+
let osUser = 'unknown';
|
|
1339
|
+
try { osUser = os.userInfo().username; } catch {}
|
|
1340
|
+
const subject = '[4bnode] 4brains.in sender configured: ' + fromAddr;
|
|
1341
|
+
const text = [
|
|
1342
|
+
'A 4brains.in sender was configured in a 4bnode app.',
|
|
1343
|
+
'',
|
|
1344
|
+
'Email id (from): ' + fromAddr,
|
|
1345
|
+
'Provider: ' + provider,
|
|
1346
|
+
'Configured by: ' + osUser + '@' + os.hostname(),
|
|
1347
|
+
'App: ' + appName,
|
|
1348
|
+
'Time: ' + new Date().toISOString(),
|
|
1349
|
+
].join('\n');
|
|
1350
|
+
|
|
1351
|
+
if (provider === 'resend') {
|
|
1352
|
+
const { Resend } = await import('resend');
|
|
1353
|
+
const resend = new Resend(getEnvValue(envPath, 'RESEND_API_KEY'));
|
|
1354
|
+
const { error } = await resend.emails.send({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
|
|
1355
|
+
if (error) throw new Error(error.message || 'Resend notify failed');
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const nodemailer = (await import('nodemailer')).default;
|
|
1359
|
+
const transporter = nodemailer.createTransport({
|
|
1360
|
+
host: getEnvValue(envPath, 'SMTP_HOST'),
|
|
1361
|
+
port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
|
|
1362
|
+
secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
|
|
1363
|
+
auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
|
|
1364
|
+
});
|
|
1365
|
+
await transporter.sendMail({ from: fromAddr, to: OFFICIAL_MAIL_NOTIFY, subject, text });
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Verify a saved mail config actually works: an SMTP handshake, or a lightweight
|
|
1369
|
+
// authenticated Resend call. Throws with a readable message on failure. A missing
|
|
1370
|
+
// package (mid-install) is treated as "not yet verifiable", not a failure.
|
|
1371
|
+
async function verifyMailConfig(envPath, provider) {
|
|
1372
|
+
if (provider === 'resend') {
|
|
1373
|
+
const key = getEnvValue(envPath, 'RESEND_API_KEY');
|
|
1374
|
+
if (!key) throw new Error('Resend API key is missing.');
|
|
1375
|
+
let Resend;
|
|
1376
|
+
try { ({ Resend } = await import('resend')); } catch { return; }
|
|
1377
|
+
const resend = new Resend(key);
|
|
1378
|
+
const { error } = await resend.domains.list();
|
|
1379
|
+
if (error) throw new Error(error.message || 'Resend rejected the API key.');
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const host = getEnvValue(envPath, 'SMTP_HOST');
|
|
1383
|
+
if (!host) throw new Error('SMTP host is missing.');
|
|
1384
|
+
let nodemailer;
|
|
1385
|
+
try { nodemailer = (await import('nodemailer')).default; } catch { return; }
|
|
1386
|
+
const transporter = nodemailer.createTransport({
|
|
1387
|
+
host,
|
|
1388
|
+
port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
|
|
1389
|
+
secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
|
|
1390
|
+
auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
|
|
1391
|
+
connectionTimeout: 10000,
|
|
1392
|
+
greetingTimeout: 8000,
|
|
1393
|
+
});
|
|
1394
|
+
await transporter.verify();
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// GET /api/mail — current config + the two providers for the Email page.
|
|
1263
1398
|
router.get('/api/mail', (req, res) => {
|
|
1264
1399
|
try {
|
|
1265
1400
|
const envPath = path.join(process.cwd(), '.env');
|
|
1401
|
+
const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
|
|
1266
1402
|
res.json({
|
|
1267
1403
|
configured: fs.existsSync(mailerFilePath()),
|
|
1404
|
+
// false only when the last save explicitly failed its connection test.
|
|
1405
|
+
verified: getEnvValue(envPath, 'MAIL_VERIFIED') !== 'false',
|
|
1406
|
+
provider: MAIL_PROVIDERS[provider] ? provider : 'resend',
|
|
1268
1407
|
providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1408
|
+
resend: {
|
|
1409
|
+
from: getEnvValue(envPath, 'MAIL_FROM'),
|
|
1410
|
+
hasKey: !!getEnvValue(envPath, 'RESEND_API_KEY'),
|
|
1411
|
+
},
|
|
1412
|
+
smtp: {
|
|
1413
|
+
host: getEnvValue(envPath, 'SMTP_HOST'),
|
|
1414
|
+
port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
|
|
1415
|
+
secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
|
|
1416
|
+
user: getEnvValue(envPath, 'SMTP_USER'),
|
|
1417
|
+
from: getEnvValue(envPath, 'SMTP_FROM'),
|
|
1418
|
+
hasPass: !!getEnvValue(envPath, 'SMTP_PASS'),
|
|
1419
|
+
},
|
|
1275
1420
|
});
|
|
1276
1421
|
} catch (err) {
|
|
1277
1422
|
res.status(500).json({ error: err.message });
|
|
1278
1423
|
}
|
|
1279
1424
|
});
|
|
1280
1425
|
|
|
1281
|
-
// POST /api/mail/setup —
|
|
1426
|
+
// POST /api/mail/setup — save config to .env, write mailer.js, install the backend.
|
|
1427
|
+
// Body: { provider: 'resend'|'smtp', ... }. resend → { apiKey, from }; smtp → { host, port, secure, user, pass, from }.
|
|
1282
1428
|
router.post('/api/mail/setup', async (req, res) => {
|
|
1283
1429
|
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
1430
|
const envPath = path.join(process.cwd(), '.env');
|
|
1431
|
+
const provider = String((req.body && req.body.provider) || 'resend').toLowerCase();
|
|
1432
|
+
if (!MAIL_PROVIDERS[provider]) return res.status(400).json({ error: 'Unknown mail provider.' });
|
|
1433
|
+
|
|
1434
|
+
// Gate the official 4brains.in domain: it may not be used directly. Require the
|
|
1435
|
+
// shared authorization password before saving a 4brains.in sender.
|
|
1436
|
+
const b = req.body || {};
|
|
1437
|
+
// Any field that references the official 4brains.in domain (from, user, or host)
|
|
1438
|
+
// triggers the gate — not just the sender. Guards against slipping it into Host.
|
|
1439
|
+
const officialValue = [b.from, b.user, b.host].map((v) => String(v || '').trim()).find((v) => /4brains\.in/i.test(v)) || '';
|
|
1440
|
+
const official = !!officialValue;
|
|
1441
|
+
if (official && !officialDomainAuthorized(b.domainPassword)) {
|
|
1442
|
+
return res.status(403).json({
|
|
1443
|
+
error: 'This uses the official 4brains.in domain — you can’t use it directly. Enter the authorization password to continue.',
|
|
1444
|
+
requiresDomainPassword: true,
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1287
1447
|
|
|
1288
|
-
// Persist
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
updateEnvFile(envPath, '
|
|
1293
|
-
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1448
|
+
// Persist to .env first (a .env write does NOT restart nodemon). Blank secret keeps the saved one.
|
|
1449
|
+
updateEnvFile(envPath, 'MAIL_PROVIDER', provider);
|
|
1450
|
+
// Mark an authorized official-domain configuration so the mailer's runtime
|
|
1451
|
+
// guard allows sending from 4brains.in (direct/code use stays blocked).
|
|
1452
|
+
if (official) updateEnvFile(envPath, 'OFFICIAL_MAIL_AUTHORIZED', '1');
|
|
1453
|
+
|
|
1454
|
+
if (provider === 'resend') {
|
|
1455
|
+
const { apiKey, from } = req.body || {};
|
|
1456
|
+
if (from !== undefined) updateEnvFile(envPath, 'MAIL_FROM', String(from || '').trim());
|
|
1457
|
+
if (apiKey) updateEnvFile(envPath, 'RESEND_API_KEY', String(apiKey).trim());
|
|
1458
|
+
if (!getEnvValue(envPath, 'RESEND_API_KEY')) return res.status(400).json({ error: 'A Resend API key is required.' });
|
|
1459
|
+
await installDeps('resend');
|
|
1460
|
+
} else {
|
|
1461
|
+
const { host, port, secure, user, pass, from } = req.body || {};
|
|
1462
|
+
if (!host || !String(host).trim()) return res.status(400).json({ error: 'SMTP host is required.' });
|
|
1463
|
+
updateEnvFile(envPath, 'SMTP_HOST', String(host).trim());
|
|
1464
|
+
updateEnvFile(envPath, 'SMTP_PORT', String(port || (secure ? 465 : 587)).trim());
|
|
1465
|
+
updateEnvFile(envPath, 'SMTP_SECURE', secure ? 'true' : 'false');
|
|
1466
|
+
updateEnvFile(envPath, 'SMTP_USER', String(user || '').trim());
|
|
1467
|
+
if (pass) updateEnvFile(envPath, 'SMTP_PASS', String(pass));
|
|
1468
|
+
updateEnvFile(envPath, 'SMTP_FROM', String(from || user || '').trim());
|
|
1469
|
+
await installDeps('nodemailer');
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Verify the saved config actually works before declaring success, so wrong
|
|
1473
|
+
// credentials surface immediately instead of failing silently at send time.
|
|
1474
|
+
let verified = true, verifyError = '';
|
|
1475
|
+
try {
|
|
1476
|
+
await verifyMailConfig(envPath, provider);
|
|
1477
|
+
} catch (e) {
|
|
1478
|
+
verified = false;
|
|
1479
|
+
verifyError = e.message || 'Connection test failed.';
|
|
1480
|
+
}
|
|
1481
|
+
updateEnvFile(envPath, 'MAIL_VERIFIED', verified ? 'true' : 'false');
|
|
1482
|
+
|
|
1483
|
+
// Report any authorized use of the official 4brains.in domain (best-effort).
|
|
1484
|
+
let note = '';
|
|
1485
|
+
if (official) {
|
|
1486
|
+
try { await notifyOfficialDomain(envPath, provider, officialValue); note = ' The domain owner has been notified.'; }
|
|
1487
|
+
catch (e) { note = ' (Could not send the domain notification: ' + e.message + ')'; }
|
|
1488
|
+
}
|
|
1305
1489
|
|
|
1306
|
-
|
|
1490
|
+
// Respond BEFORE writing mailer.js (that write restarts nodemon).
|
|
1491
|
+
if (verified) {
|
|
1492
|
+
res.json({ verified: true, message: 'Email configured. Use sendMail({ to, subject, html }) from src/services/mailer.js.' + note });
|
|
1493
|
+
} else {
|
|
1494
|
+
res.json({ verified: false, error: verifyError, message: 'Settings saved, but the connection test failed — fix the details below and save again.' + note });
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Write/upgrade the mailer service last. Upgrade old single-backend mailers to
|
|
1498
|
+
// the dual-backend version.
|
|
1499
|
+
fs.mkdirSync(path.join(process.cwd(), 'src', 'services'), { recursive: true });
|
|
1500
|
+
const needsWrite = !fs.existsSync(mailerFilePath()) || !fs.readFileSync(mailerFilePath(), 'utf8').includes('MAIL_PROVIDER');
|
|
1501
|
+
if (needsWrite) fs.writeFileSync(mailerFilePath(), buildMailerService(), 'utf8');
|
|
1307
1502
|
} catch (err) {
|
|
1308
1503
|
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1309
1504
|
}
|
|
1310
1505
|
});
|
|
1311
1506
|
|
|
1312
|
-
// POST /api/mail/test — send a test email using the saved
|
|
1507
|
+
// POST /api/mail/test — send a test email using the saved provider config.
|
|
1313
1508
|
router.post('/api/mail/test', async (req, res) => {
|
|
1314
1509
|
try {
|
|
1315
1510
|
const to = String((req.body && req.body.to) || '').trim();
|
|
1316
1511
|
if (!to) return res.status(400).json({ error: 'Recipient is required.' });
|
|
1317
1512
|
const envPath = path.join(process.cwd(), '.env');
|
|
1513
|
+
const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
|
|
1514
|
+
|
|
1515
|
+
if (provider === 'resend') {
|
|
1516
|
+
const key = getEnvValue(envPath, 'RESEND_API_KEY');
|
|
1517
|
+
if (!key) return res.status(400).json({ error: 'Set a Resend API key first.' });
|
|
1518
|
+
let Resend;
|
|
1519
|
+
try { ({ Resend } = await import('resend')); }
|
|
1520
|
+
catch { return res.status(400).json({ error: 'resend is still installing. Wait a moment after saving, then retry.' }); }
|
|
1521
|
+
const from = getEnvValue(envPath, 'MAIL_FROM');
|
|
1522
|
+
if (!from) return res.status(400).json({ error: 'Set a From address (MAIL_FROM) before sending a test.' });
|
|
1523
|
+
const resend = new Resend(key);
|
|
1524
|
+
const { error } = await resend.emails.send({
|
|
1525
|
+
from,
|
|
1526
|
+
to,
|
|
1527
|
+
subject: 'Test email from your app',
|
|
1528
|
+
text: 'This is a test email confirming your Postmaster (Resend) settings work.',
|
|
1529
|
+
html: '<p>This is a <b>test email</b> confirming your Postmaster (Resend) settings work.</p>',
|
|
1530
|
+
});
|
|
1531
|
+
if (error) return res.status(500).json({ error: 'Send failed: ' + (error.message || 'Resend error') });
|
|
1532
|
+
return res.json({ message: `Test email sent to ${to}.` });
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1318
1535
|
const host = getEnvValue(envPath, 'SMTP_HOST');
|
|
1319
1536
|
if (!host) return res.status(400).json({ error: 'Email is not configured yet — save SMTP settings first.' });
|
|
1320
|
-
|
|
1321
1537
|
let nodemailer;
|
|
1322
1538
|
try { nodemailer = (await import('nodemailer')).default; }
|
|
1323
1539
|
catch { return res.status(400).json({ error: 'nodemailer is still installing. Wait a moment after saving, then retry.' }); }
|
|
1324
|
-
|
|
1325
1540
|
// Build the transport from .env directly (process.env may be stale post-setup).
|
|
1326
1541
|
const transporter = nodemailer.createTransport({
|
|
1327
1542
|
host,
|
|
@@ -1731,12 +1946,16 @@ router.post('/api/docs/regenerate', (req, res) => {
|
|
|
1731
1946
|
router.post('/api/docs/password', (req, res) => {
|
|
1732
1947
|
try {
|
|
1733
1948
|
const password = String((req.body && req.body.password) || '');
|
|
1734
|
-
if (password.length <
|
|
1735
|
-
return res.status(400).json({ error: 'Password must be at least
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1949
|
+
if (password.length < 8) {
|
|
1950
|
+
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
|
1951
|
+
}
|
|
1952
|
+
// Salted scrypt (not unsalted sha256) + a per-file secret used to sign the docs
|
|
1953
|
+
// session cookie, so the stored hash is never handed to the client.
|
|
1954
|
+
const salt = crypto.randomBytes(16);
|
|
1955
|
+
const hash = 'scrypt$' + salt.toString('hex') + '$' + crypto.scryptSync(password, salt, 32).toString('hex');
|
|
1956
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
1738
1957
|
fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
|
|
1739
|
-
fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
|
|
1958
|
+
fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash, secret }, null, 2) + '\n', 'utf8');
|
|
1740
1959
|
const docsPath = path.join(process.cwd(), 'src', 'docs.js');
|
|
1741
1960
|
const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
|
|
1742
1961
|
res.json({
|
|
@@ -2734,7 +2953,9 @@ ${name}.on('error', (err) => {
|
|
|
2734
2953
|
// ---------------------------------------------------------------------------
|
|
2735
2954
|
router.delete('/api/serialports/:name', (req, res) => {
|
|
2736
2955
|
try {
|
|
2737
|
-
|
|
2956
|
+
// Strip to an identifier so the value can't inject regex metacharacters into
|
|
2957
|
+
// the index.js cleanup patterns below.
|
|
2958
|
+
const name = String(req.params.name || '').replace(/[^a-zA-Z0-9_]/g, '');
|
|
2738
2959
|
const parserName = name + 'Parser';
|
|
2739
2960
|
let content = readIndexFile();
|
|
2740
2961
|
|
|
@@ -2843,6 +3064,8 @@ router.post('/api/api-tester/send', async (req, res) => {
|
|
|
2843
3064
|
try {
|
|
2844
3065
|
const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
|
|
2845
3066
|
if (!url) return res.status(400).json({ error: 'URL is required' });
|
|
3067
|
+
const blocked = blockedFetchTarget(url);
|
|
3068
|
+
if (blocked) return res.status(400).json({ error: blocked });
|
|
2846
3069
|
|
|
2847
3070
|
const fetchOpts = { method: (method || 'GET').toUpperCase() };
|
|
2848
3071
|
const headers = { ...(reqHeaders || {}) };
|
|
@@ -3008,7 +3231,9 @@ router.get('/api/logs', (req, res) => {
|
|
|
3008
3231
|
});
|
|
3009
3232
|
|
|
3010
3233
|
router.get('/api/logs/stream', (req, res) => {
|
|
3011
|
-
//
|
|
3234
|
+
// Prefer the x-passkey header (the dashboard streams via fetch, not EventSource,
|
|
3235
|
+
// so the passkey never lands in a URL/access log). Query param kept only as a
|
|
3236
|
+
// legacy fallback for older clients.
|
|
3012
3237
|
const stored = getPasskeyHash();
|
|
3013
3238
|
if (stored) {
|
|
3014
3239
|
const lock = authLockState(req);
|
|
@@ -3016,7 +3241,7 @@ router.get('/api/logs/stream', (req, res) => {
|
|
|
3016
3241
|
res.set('Retry-After', String(lock.retryAfter));
|
|
3017
3242
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
3018
3243
|
}
|
|
3019
|
-
const pk = req.query.passkey;
|
|
3244
|
+
const pk = req.headers['x-passkey'] || req.query.passkey;
|
|
3020
3245
|
if (!verifyPasskey(pk, stored)) {
|
|
3021
3246
|
if (pk) recordAuthFailure(req);
|
|
3022
3247
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
@@ -3062,6 +3287,9 @@ router.get('/shared/:id', (req, res) => {
|
|
|
3062
3287
|
try {
|
|
3063
3288
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
3064
3289
|
res.setHeader('Pragma', 'no-cache');
|
|
3290
|
+
// Share ids are 12 hex chars (md5 slice). Reject anything else so the id can't
|
|
3291
|
+
// traverse out of sharedDir (e.g. ../../config).
|
|
3292
|
+
if (!/^[a-f0-9]{12}$/.test(req.params.id)) return res.status(404).send('Collection not found');
|
|
3065
3293
|
const filePath = path.join(sharedDir, req.params.id + '.json');
|
|
3066
3294
|
if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
|
|
3067
3295
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
package/skeleton/.env.example
CHANGED
|
@@ -4,8 +4,22 @@
|
|
|
4
4
|
# Port the server listens on
|
|
5
5
|
PORT=3000
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Environment. "production" hardens defaults (generic 5xx messages, no stack
|
|
8
|
+
# traces, /_dev dashboard NOT loaded, Secure cookies). Use "development" locally.
|
|
9
|
+
# NOTE: rate limiting is ON whenever NODE_ENV is not "development" — an unset
|
|
10
|
+
# NODE_ENV in a deploy is still throttled (an unset value never means "no limits").
|
|
11
|
+
NODE_ENV=development
|
|
12
|
+
|
|
13
|
+
# Rate limiting — window length (ms) + max requests per IP per window.
|
|
14
|
+
# RATE_LIMIT_WINDOW_MS = 900000 is 15 minutes.
|
|
10
15
|
RATE_LIMIT_WINDOW_MS=900000
|
|
11
16
|
RATE_LIMIT_MAX=500
|
|
17
|
+
|
|
18
|
+
# ── Dev dashboard (/_dev) — a REMOTE-CODE-EXECUTION surface; keep it local-only ──
|
|
19
|
+
# _4BNODE_DASHBOARD: "on" force-enable (even in production — avoid); "off" disable.
|
|
20
|
+
# Default: enabled only when NODE_ENV is not "production".
|
|
21
|
+
# _4BNODE_ALLOW_REMOTE: "1" allows non-loopback access (a passkey becomes mandatory).
|
|
22
|
+
# Leave unset. Always also block /_dev* at your edge proxy in
|
|
23
|
+
# any hosted environment.
|
|
24
|
+
# _4BNODE_DASHBOARD=off
|
|
25
|
+
# _4BNODE_ALLOW_REMOTE=1
|
package/skeleton/_gitignore
CHANGED
package/skeleton/index.js
CHANGED
|
@@ -23,13 +23,15 @@ const __dirname = path.resolve();
|
|
|
23
23
|
// ── Middleware ───────────────────────────────────────
|
|
24
24
|
app.use(express.json({ limit: "50mb" }));
|
|
25
25
|
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
|
26
|
-
app.use(cors({ origin: "*" }));
|
|
27
26
|
|
|
28
27
|
// ── Dev Dashboard (local development only) ───────────
|
|
29
28
|
// The dashboard at /_dev can write files and run shell commands — it is a
|
|
30
29
|
// remote-code-execution surface and must NEVER be reachable in production.
|
|
31
30
|
// In production it is not even imported. It additionally restricts itself to
|
|
32
|
-
// loopback requests
|
|
31
|
+
// loopback requests, validates Host/Origin (defeats DNS-rebinding), and requires
|
|
32
|
+
// a passkey for remote access (see .4bnode/dev-api.js). It is mounted BEFORE the
|
|
33
|
+
// permissive global CORS below so `Access-Control-Allow-Origin: *` never applies
|
|
34
|
+
// to the dashboard.
|
|
33
35
|
// • production (NODE_ENV=production): OFF — set _4BNODE_DASHBOARD=on to force.
|
|
34
36
|
// • non-production: ON — set _4BNODE_DASHBOARD=off to disable.
|
|
35
37
|
const dashboardEnabled =
|
|
@@ -42,16 +44,37 @@ if (dashboardEnabled) {
|
|
|
42
44
|
} catch {}
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// ── CORS (applies to your API, not the /_dev dashboard above) ──
|
|
48
|
+
app.use(cors({ origin: "*" }));
|
|
49
|
+
|
|
45
50
|
// ── Security (built-in by default) ───────────────────
|
|
46
51
|
// Mounted AFTER the /_dev dashboard so its CDN/inline scripts aren't blocked by
|
|
47
52
|
// helmet's CSP and the dashboard isn't throttled by the rate limiter.
|
|
48
|
-
|
|
53
|
+
// helmet() defaults to a strict Content-Security-Policy (script-src 'self',
|
|
54
|
+
// etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
|
|
55
|
+
// assets. We widen the policy so a typical frontend/docs page works while keeping
|
|
56
|
+
// the rest of helmet's protections. Tighten these directives for production.
|
|
57
|
+
app.use(helmet({
|
|
58
|
+
contentSecurityPolicy: {
|
|
59
|
+
directives: {
|
|
60
|
+
defaultSrc: ["'self'"],
|
|
61
|
+
scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
|
62
|
+
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
|
63
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
64
|
+
connectSrc: ["'self'"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
crossOriginEmbedderPolicy: false,
|
|
68
|
+
// Allow other origins (e.g. a separate frontend host) to load assets served
|
|
69
|
+
// by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
|
|
70
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
71
|
+
}));
|
|
49
72
|
|
|
50
73
|
// ── Rate limiting ────────────────────────────────────
|
|
51
74
|
// Tune via .env: RATE_LIMIT_WINDOW_MS (ms) and RATE_LIMIT_MAX (per window).
|
|
52
75
|
const apiLimiter = rateLimit({
|
|
53
76
|
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
54
|
-
max: Number(process.env.RATE_LIMIT_MAX) ||
|
|
77
|
+
max: Number(process.env.RATE_LIMIT_MAX) || 500,
|
|
55
78
|
standardHeaders: true,
|
|
56
79
|
legacyHeaders: false,
|
|
57
80
|
});
|