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
|
@@ -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.
|
|
@@ -34,14 +35,43 @@ import {
|
|
|
34
35
|
buildCrudRouteFile,
|
|
35
36
|
addDepsToPackageJson,
|
|
36
37
|
extractRequestFields,
|
|
38
|
+
extractFileFields,
|
|
39
|
+
requiredFieldsFromZod,
|
|
37
40
|
buildOpenApiSpec,
|
|
38
41
|
buildDocsRouter,
|
|
39
42
|
MAIL_PROVIDERS,
|
|
40
43
|
buildMailerService,
|
|
44
|
+
isOfficialMailDomain,
|
|
45
|
+
OFFICIAL_MAIL_DOMAIN_PASSWORD,
|
|
46
|
+
OFFICIAL_MAIL_NOTIFY,
|
|
41
47
|
} from './codegen.js';
|
|
42
48
|
|
|
43
49
|
const router = express.Router();
|
|
44
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
|
+
|
|
45
75
|
// ── Network restriction ─────────────────────────────────────────────────────
|
|
46
76
|
// This router can write files and run shell commands — it is a remote-code-
|
|
47
77
|
// execution surface. Refuse any request that does not originate from the local
|
|
@@ -66,11 +96,35 @@ router.use((req, res, next) => {
|
|
|
66
96
|
const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
|
|
67
97
|
function isLoopbackRequest(req) {
|
|
68
98
|
const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
|
|
69
|
-
|
|
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; }
|
|
70
117
|
}
|
|
71
118
|
router.use((req, res, next) => {
|
|
72
|
-
if (ALLOW_REMOTE
|
|
73
|
-
|
|
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();
|
|
74
128
|
});
|
|
75
129
|
|
|
76
130
|
// ── Console Log Capture ─────────────────────────────────────────────────────
|
|
@@ -173,6 +227,16 @@ function recordAuthFailure(req) {
|
|
|
173
227
|
function recordAuthSuccess(req) {
|
|
174
228
|
_authFails.delete(authClientKey(req));
|
|
175
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
|
+
}
|
|
176
240
|
|
|
177
241
|
// Verify passkey endpoint (no auth middleware needed here)
|
|
178
242
|
router.post('/api/auth', (req, res) => {
|
|
@@ -203,6 +267,7 @@ router.post('/api/auth', (req, res) => {
|
|
|
203
267
|
return res.status(401).json({ error: 'Invalid passkey' });
|
|
204
268
|
}
|
|
205
269
|
recordAuthSuccess(req);
|
|
270
|
+
upgradeLegacyPasskey(passkey, stored);
|
|
206
271
|
res.json({ ok: true });
|
|
207
272
|
});
|
|
208
273
|
|
|
@@ -228,6 +293,7 @@ router.use('/api', (req, res, next) => {
|
|
|
228
293
|
if (passkey) recordAuthFailure(req);
|
|
229
294
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
230
295
|
}
|
|
296
|
+
upgradeLegacyPasskey(passkey, stored);
|
|
231
297
|
next();
|
|
232
298
|
});
|
|
233
299
|
|
|
@@ -500,7 +566,7 @@ router.get('/api/models', (req, res) => {
|
|
|
500
566
|
// GET /api/models/:name
|
|
501
567
|
router.get('/api/models/:name', (req, res) => {
|
|
502
568
|
try {
|
|
503
|
-
const name = req.params.name;
|
|
569
|
+
const name = safeStem(req.params.name);
|
|
504
570
|
const { fields, indexes } = getModelSchemaDetailed(name);
|
|
505
571
|
res.json({ name, fields, indexes });
|
|
506
572
|
} catch (err) {
|
|
@@ -511,7 +577,7 @@ router.get('/api/models/:name', (req, res) => {
|
|
|
511
577
|
// GET /api/models/:name/data — fetch stored documents from MongoDB collection
|
|
512
578
|
router.get('/api/models/:name/data', async (req, res) => {
|
|
513
579
|
try {
|
|
514
|
-
const name = req.params.name;
|
|
580
|
+
const name = safeStem(req.params.name);
|
|
515
581
|
const rawLimit = parseInt(req.query.limit);
|
|
516
582
|
const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
|
|
517
583
|
const skip = parseInt(req.query.skip) || 0;
|
|
@@ -546,7 +612,7 @@ router.get('/api/models/:name/data', async (req, res) => {
|
|
|
546
612
|
// GET /api/models/:name/share — generate shareable endpoint documentation
|
|
547
613
|
router.get('/api/models/:name/share', (req, res) => {
|
|
548
614
|
try {
|
|
549
|
-
const name = req.params.name;
|
|
615
|
+
const name = safeStem(req.params.name);
|
|
550
616
|
const { fields } = getModelSchemaDetailed(name);
|
|
551
617
|
const routes = listRoutes();
|
|
552
618
|
const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
|
|
@@ -610,7 +676,7 @@ router.post('/api/models', (req, res) => {
|
|
|
610
676
|
// PUT /api/models/:name
|
|
611
677
|
router.put('/api/models/:name', (req, res) => {
|
|
612
678
|
try {
|
|
613
|
-
const name = req.params.name;
|
|
679
|
+
const name = safeStem(req.params.name);
|
|
614
680
|
const { fields, indexes } = req.body;
|
|
615
681
|
const pascalName = toPascalCase(name);
|
|
616
682
|
|
|
@@ -631,7 +697,7 @@ router.put('/api/models/:name', (req, res) => {
|
|
|
631
697
|
// DELETE /api/models/:name
|
|
632
698
|
router.delete('/api/models/:name', (req, res) => {
|
|
633
699
|
try {
|
|
634
|
-
const name = req.params.name;
|
|
700
|
+
const name = safeStem(req.params.name);
|
|
635
701
|
const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
|
|
636
702
|
if (!fs.existsSync(filePath)) {
|
|
637
703
|
return res.status(404).json({ error: `Model "${name}" not found.` });
|
|
@@ -658,10 +724,10 @@ router.get('/api/routes', (req, res) => {
|
|
|
658
724
|
// GET /api/routes/:name/content
|
|
659
725
|
router.get('/api/routes/:name/content', (req, res) => {
|
|
660
726
|
try {
|
|
661
|
-
const name = req.params.name;
|
|
662
|
-
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');
|
|
663
729
|
if (!fs.existsSync(filePath)) {
|
|
664
|
-
return res.status(404).json({ error: `Route file "${name}" not found.` });
|
|
730
|
+
return res.status(404).json({ error: `Route file "${name}.js" not found.` });
|
|
665
731
|
}
|
|
666
732
|
|
|
667
733
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
@@ -674,17 +740,30 @@ router.get('/api/routes/:name/content', (req, res) => {
|
|
|
674
740
|
// DELETE /api/routes/:name
|
|
675
741
|
router.delete('/api/routes/:name', (req, res) => {
|
|
676
742
|
try {
|
|
677
|
-
const name = req.params.name;
|
|
678
|
-
const routeFile = name
|
|
679
|
-
const safeName =
|
|
743
|
+
const name = safeStem(req.params.name);
|
|
744
|
+
const routeFile = name + '.js';
|
|
745
|
+
const safeName = name;
|
|
680
746
|
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
681
747
|
|
|
682
748
|
if (!fs.existsSync(filePath)) {
|
|
683
749
|
return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
|
|
684
750
|
}
|
|
685
751
|
|
|
752
|
+
// Find the Zod validator this route uses (if any) before deleting the route,
|
|
753
|
+
// so we can clean it up too — otherwise validators/<name>.js is orphaned.
|
|
754
|
+
let validatorPath = null;
|
|
755
|
+
try {
|
|
756
|
+
const routeContent = fs.readFileSync(filePath, 'utf8');
|
|
757
|
+
const vi = routeContent.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
|
|
758
|
+
if (vi) validatorPath = path.join(process.cwd(), 'src', 'validators', vi[1] + '.js');
|
|
759
|
+
} catch {}
|
|
760
|
+
|
|
686
761
|
// Delete the route file
|
|
687
762
|
fs.unlinkSync(filePath);
|
|
763
|
+
// Delete its validator (only this route used it — validators are 1:1 with routes).
|
|
764
|
+
if (validatorPath && fs.existsSync(validatorPath)) {
|
|
765
|
+
try { fs.unlinkSync(validatorPath); } catch {}
|
|
766
|
+
}
|
|
688
767
|
refreshDocsSpec();
|
|
689
768
|
|
|
690
769
|
// Send response BEFORE modifying index.js (nodemon restart kills connection)
|
|
@@ -695,9 +774,9 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
695
774
|
if (fs.existsSync(indexPath)) {
|
|
696
775
|
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
697
776
|
// Remove import line
|
|
698
|
-
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'), '');
|
|
699
778
|
// Remove app.use registration line
|
|
700
|
-
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'), '');
|
|
701
780
|
fs.writeFileSync(indexPath, indexContent, 'utf8');
|
|
702
781
|
}
|
|
703
782
|
} catch (err) {
|
|
@@ -710,7 +789,7 @@ router.delete('/api/routes/:name', (req, res) => {
|
|
|
710
789
|
// ---------------------------------------------------------------------------
|
|
711
790
|
router.post('/api/routes/:name/add-method', async (req, res) => {
|
|
712
791
|
try {
|
|
713
|
-
const name = req.params.name
|
|
792
|
+
const name = safeStem(req.params.name);
|
|
714
793
|
const routeFile = name + '.js';
|
|
715
794
|
const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
|
|
716
795
|
if (!fs.existsSync(filePath)) {
|
|
@@ -1220,7 +1299,7 @@ router.get('/api/security/rate-limit', (req, res) => {
|
|
|
1220
1299
|
try {
|
|
1221
1300
|
const envPath = path.join(process.cwd(), '.env');
|
|
1222
1301
|
const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
|
|
1223
|
-
const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) ||
|
|
1302
|
+
const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 500;
|
|
1224
1303
|
res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
|
|
1225
1304
|
} catch (err) {
|
|
1226
1305
|
res.status(500).json({ error: err.message });
|
|
@@ -1230,7 +1309,7 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1230
1309
|
try {
|
|
1231
1310
|
const envPath = path.join(process.cwd(), '.env');
|
|
1232
1311
|
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) ||
|
|
1312
|
+
const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 500));
|
|
1234
1313
|
updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
|
|
1235
1314
|
updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
|
|
1236
1315
|
res.json({ message: 'Rate limit updated.', windowMinutes, max });
|
|
@@ -1239,6 +1318,245 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1239
1318
|
}
|
|
1240
1319
|
});
|
|
1241
1320
|
|
|
1321
|
+
// ---------------------------------------------------------------------------
|
|
1322
|
+
// Email (nodemailer) — provider presets, SMTP config in .env, install + test
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
1324
|
+
const mailerFilePath = () => path.join(process.cwd(), 'src', 'services', 'mailer.js');
|
|
1325
|
+
|
|
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.
|
|
1398
|
+
router.get('/api/mail', (req, res) => {
|
|
1399
|
+
try {
|
|
1400
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1401
|
+
const provider = (getEnvValue(envPath, 'MAIL_PROVIDER') || 'resend').toLowerCase();
|
|
1402
|
+
res.json({
|
|
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',
|
|
1407
|
+
providers: Object.entries(MAIL_PROVIDERS).map(([id, p]) => ({ id, ...p })),
|
|
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
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
} catch (err) {
|
|
1422
|
+
res.status(500).json({ error: err.message });
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
|
|
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 }.
|
|
1428
|
+
router.post('/api/mail/setup', async (req, res) => {
|
|
1429
|
+
try {
|
|
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
|
+
}
|
|
1447
|
+
|
|
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
|
+
}
|
|
1489
|
+
|
|
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');
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// POST /api/mail/test — send a test email using the saved provider config.
|
|
1508
|
+
router.post('/api/mail/test', async (req, res) => {
|
|
1509
|
+
try {
|
|
1510
|
+
const to = String((req.body && req.body.to) || '').trim();
|
|
1511
|
+
if (!to) return res.status(400).json({ error: 'Recipient is required.' });
|
|
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
|
+
|
|
1535
|
+
const host = getEnvValue(envPath, 'SMTP_HOST');
|
|
1536
|
+
if (!host) return res.status(400).json({ error: 'Email is not configured yet — save SMTP settings first.' });
|
|
1537
|
+
let nodemailer;
|
|
1538
|
+
try { nodemailer = (await import('nodemailer')).default; }
|
|
1539
|
+
catch { return res.status(400).json({ error: 'nodemailer is still installing. Wait a moment after saving, then retry.' }); }
|
|
1540
|
+
// Build the transport from .env directly (process.env may be stale post-setup).
|
|
1541
|
+
const transporter = nodemailer.createTransport({
|
|
1542
|
+
host,
|
|
1543
|
+
port: Number(getEnvValue(envPath, 'SMTP_PORT')) || 587,
|
|
1544
|
+
secure: getEnvValue(envPath, 'SMTP_SECURE') === 'true',
|
|
1545
|
+
auth: { user: getEnvValue(envPath, 'SMTP_USER'), pass: getEnvValue(envPath, 'SMTP_PASS') },
|
|
1546
|
+
});
|
|
1547
|
+
await transporter.sendMail({
|
|
1548
|
+
from: getEnvValue(envPath, 'SMTP_FROM') || getEnvValue(envPath, 'SMTP_USER'),
|
|
1549
|
+
to,
|
|
1550
|
+
subject: 'Test email from your app',
|
|
1551
|
+
text: 'This is a test email confirming your SMTP settings work.',
|
|
1552
|
+
html: '<p>This is a <b>test email</b> confirming your SMTP settings work.</p>',
|
|
1553
|
+
});
|
|
1554
|
+
res.json({ message: `Test email sent to ${to}.` });
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
res.status(500).json({ error: 'Send failed: ' + err.message });
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1242
1560
|
// ---------------------------------------------------------------------------
|
|
1243
1561
|
// AI Builder — describe a backend in English, generate models + CRUD routes
|
|
1244
1562
|
// ---------------------------------------------------------------------------
|
|
@@ -1523,21 +1841,44 @@ function collectEndpoints() {
|
|
|
1523
1841
|
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
1524
1842
|
const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
|
|
1525
1843
|
|
|
1844
|
+
// The API's real required-ness comes from the Zod validator (what actually
|
|
1845
|
+
// gates the request), not the Mongoose model. Parse the route's validator file
|
|
1846
|
+
// so OpenAPI `required` reflects the API contract, not the DB schema.
|
|
1847
|
+
const validatorImport = content.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
|
|
1848
|
+
let validatorReq = null;
|
|
1849
|
+
if (validatorImport) {
|
|
1850
|
+
const vPath = path.join(process.cwd(), 'src', 'validators', validatorImport[1] + '.js');
|
|
1851
|
+
if (fs.existsSync(vPath)) {
|
|
1852
|
+
try { validatorReq = requiredFieldsFromZod(fs.readFileSync(vPath, 'utf8')); } catch {}
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1526
1856
|
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
|
|
1527
1857
|
let m;
|
|
1528
1858
|
while ((m = epRegex.exec(content)) !== null) {
|
|
1529
1859
|
const method = m[1].toUpperCase();
|
|
1530
1860
|
const epath = m[2];
|
|
1531
1861
|
const body = m[3] || '';
|
|
1862
|
+
// PATCH wires `validate(schema.partial())`, making every field optional.
|
|
1863
|
+
const usesPartial = /\.partial\s*\(\s*\)/.test(body);
|
|
1532
1864
|
const bodyFields = extractRequestFields(body, 'body').map((fn) => {
|
|
1533
1865
|
const meta = modelFields.find((x) => x.name === fn);
|
|
1534
|
-
|
|
1866
|
+
let required;
|
|
1867
|
+
if (usesPartial) required = false;
|
|
1868
|
+
else if (validatorReq && Object.prototype.hasOwnProperty.call(validatorReq, fn)) required = validatorReq[fn];
|
|
1869
|
+
else required = !!(meta && meta.required);
|
|
1870
|
+
return { name: fn, type: (meta && meta.type) || 'String', required };
|
|
1535
1871
|
});
|
|
1536
1872
|
const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
|
|
1537
1873
|
const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
|
|
1538
|
-
|
|
1874
|
+
// Auth is detected by the `auth` middleware on the route — NOT by scanning the
|
|
1875
|
+
// handler for "jwt"/"bearer", which would wrongly flag login/register routes
|
|
1876
|
+
// (they *sign* a JWT to return it; they don't *require* a Bearer token).
|
|
1877
|
+
const hasAuth = /\bauth\b\s*,/.test(body);
|
|
1878
|
+
// Detect multer file fields so the docs show a multipart upload + file inputs.
|
|
1879
|
+
const fileFields = extractFileFields(body);
|
|
1539
1880
|
const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
|
|
1540
|
-
out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields
|
|
1881
|
+
out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields, hasAuth });
|
|
1541
1882
|
}
|
|
1542
1883
|
});
|
|
1543
1884
|
return out;
|
|
@@ -1605,12 +1946,16 @@ router.post('/api/docs/regenerate', (req, res) => {
|
|
|
1605
1946
|
router.post('/api/docs/password', (req, res) => {
|
|
1606
1947
|
try {
|
|
1607
1948
|
const password = String((req.body && req.body.password) || '');
|
|
1608
|
-
if (password.length <
|
|
1609
|
-
return res.status(400).json({ error: 'Password must be at least
|
|
1610
|
-
}
|
|
1611
|
-
|
|
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');
|
|
1612
1957
|
fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
|
|
1613
|
-
fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
|
|
1958
|
+
fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash, secret }, null, 2) + '\n', 'utf8');
|
|
1614
1959
|
const docsPath = path.join(process.cwd(), 'src', 'docs.js');
|
|
1615
1960
|
const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
|
|
1616
1961
|
res.json({
|
|
@@ -2608,7 +2953,9 @@ ${name}.on('error', (err) => {
|
|
|
2608
2953
|
// ---------------------------------------------------------------------------
|
|
2609
2954
|
router.delete('/api/serialports/:name', (req, res) => {
|
|
2610
2955
|
try {
|
|
2611
|
-
|
|
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, '');
|
|
2612
2959
|
const parserName = name + 'Parser';
|
|
2613
2960
|
let content = readIndexFile();
|
|
2614
2961
|
|
|
@@ -2717,6 +3064,8 @@ router.post('/api/api-tester/send', async (req, res) => {
|
|
|
2717
3064
|
try {
|
|
2718
3065
|
const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
|
|
2719
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 });
|
|
2720
3069
|
|
|
2721
3070
|
const fetchOpts = { method: (method || 'GET').toUpperCase() };
|
|
2722
3071
|
const headers = { ...(reqHeaders || {}) };
|
|
@@ -2882,7 +3231,9 @@ router.get('/api/logs', (req, res) => {
|
|
|
2882
3231
|
});
|
|
2883
3232
|
|
|
2884
3233
|
router.get('/api/logs/stream', (req, res) => {
|
|
2885
|
-
//
|
|
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.
|
|
2886
3237
|
const stored = getPasskeyHash();
|
|
2887
3238
|
if (stored) {
|
|
2888
3239
|
const lock = authLockState(req);
|
|
@@ -2890,7 +3241,7 @@ router.get('/api/logs/stream', (req, res) => {
|
|
|
2890
3241
|
res.set('Retry-After', String(lock.retryAfter));
|
|
2891
3242
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
2892
3243
|
}
|
|
2893
|
-
const pk = req.query.passkey;
|
|
3244
|
+
const pk = req.headers['x-passkey'] || req.query.passkey;
|
|
2894
3245
|
if (!verifyPasskey(pk, stored)) {
|
|
2895
3246
|
if (pk) recordAuthFailure(req);
|
|
2896
3247
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
@@ -2936,6 +3287,9 @@ router.get('/shared/:id', (req, res) => {
|
|
|
2936
3287
|
try {
|
|
2937
3288
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
2938
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');
|
|
2939
3293
|
const filePath = path.join(sharedDir, req.params.id + '.json');
|
|
2940
3294
|
if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
|
|
2941
3295
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
package/skeleton/.env
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
# 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.
|
|
15
|
+
RATE_LIMIT_WINDOW_MS=900000
|
|
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
|