nibula 1.2.2 → 1.2.3
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/.eleventy.js +0 -5
- package/CHANGELOG.md +8 -9
- package/README.md +1 -1
- package/bin/create.js +0 -1
- package/bin/nibula.js +1 -1
- package/nginx.conf +75 -75
- package/package.json +74 -74
- package/src/backend/_core/index.js +267 -267
- package/src/backend/_core/init.js +52 -52
- package/src/backend/_core/modules/RateLimiter.js +58 -58
- package/src/backend/_core/modules/Response.js +59 -59
- package/src/backend/api/protected/example-protected.js +23 -23
- package/src/backend/api/public/example-public.js +24 -24
- package/src/backend/backend-node.service.example +30 -30
- package/src/backend/database/Database.js +46 -46
- package/src/backend/example.config.js +37 -37
- package/src/backend/package.json +18 -18
- package/src/frontend/.htaccess +51 -51
- package/src/frontend/assets/brand/favicon.svg +54 -54
- package/src/frontend/assets/brand/logo.svg +54 -54
- package/src/frontend/data/site.json +48 -48
- package/src/frontend/web.config +55 -55
- package/tools/modules/updateOutputPath.js +29 -8
|
@@ -1,267 +1,267 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Mirror of src/backend/_core/index.php (the front controller).
|
|
5
|
-
*
|
|
6
|
-
* PHP was invoked once per request by the web server through PHP-FPM. Node is
|
|
7
|
-
* the server itself, so this file boots an HTTP listener and runs the exact same
|
|
8
|
-
* pipeline for every request:
|
|
9
|
-
*
|
|
10
|
-
* 1. request parsing (strip /api prefix, split segments, reject . / ..)
|
|
11
|
-
* 2. endpoint resolution (dynamic routing with subdirectories + route params)
|
|
12
|
-
* 3. headers + CORS
|
|
13
|
-
* 4. rate limiting
|
|
14
|
-
* 5. authentication guard for protected endpoints
|
|
15
|
-
* 6. dispatch to the matched endpoint file
|
|
16
|
-
*
|
|
17
|
-
* Endpoints keep the same authoring style as PHP: a file that receives
|
|
18
|
-
* { method, requestParams, Response, ... } and calls Response.success/error.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const http = require('http');
|
|
22
|
-
const fs = require('fs');
|
|
23
|
-
const path = require('path');
|
|
24
|
-
const crypto = require('crypto');
|
|
25
|
-
|
|
26
|
-
const { createResponse, HALT } = require('./modules/Response');
|
|
27
|
-
const RateLimiter = require('./modules/RateLimiter');
|
|
28
|
-
const { loadConfig, handleException } = require('./init');
|
|
29
|
-
|
|
30
|
-
const CORE_PATH = __dirname;
|
|
31
|
-
|
|
32
|
-
// Load configuration once (init.php loaded it per invocation; here per process).
|
|
33
|
-
const config = loadConfig();
|
|
34
|
-
|
|
35
|
-
// Environment: mirror the display_errors toggle by picking the debug flag.
|
|
36
|
-
const APP_ENV = config.APP_ENV || 'production';
|
|
37
|
-
|
|
38
|
-
// Where to look for the 404.html generated by the static build.
|
|
39
|
-
// In a Nibula build the backend lives at out/backend/, so the site root (out/)
|
|
40
|
-
// is two levels up from _core. We default to that, so 404.html is found without
|
|
41
|
-
// any extra configuration; override with the DOCUMENT_ROOT env var if needed.
|
|
42
|
-
// (equivalent of $_SERVER['DOCUMENT_ROOT'] in the PHP front controller).
|
|
43
|
-
const DOCUMENT_ROOT = process.env.DOCUMENT_ROOT || path.join(CORE_PATH, '..', '..');
|
|
44
|
-
|
|
45
|
-
const basePublic = path.join(CORE_PATH, '..', 'api', 'public');
|
|
46
|
-
const baseProtected = path.join(CORE_PATH, '..', 'api', 'protected');
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* hash_equals equivalent: constant-time string comparison, safe on unequal
|
|
50
|
-
* lengths (timingSafeEqual throws if buffers differ in length).
|
|
51
|
-
*/
|
|
52
|
-
function hashEquals(known, given) {
|
|
53
|
-
const a = Buffer.from(String(known));
|
|
54
|
-
const b = Buffer.from(String(given));
|
|
55
|
-
if (a.length !== b.length) {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
return crypto.timingSafeEqual(a, b);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Serve the static 404 page if available, else a plain fallback. */
|
|
62
|
-
function send404(res) {
|
|
63
|
-
res.statusCode = 404;
|
|
64
|
-
const errorPage = DOCUMENT_ROOT ? path.join(DOCUMENT_ROOT, '404.html') : '';
|
|
65
|
-
if (errorPage && fs.existsSync(errorPage)) {
|
|
66
|
-
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
|
|
67
|
-
res.end(fs.readFileSync(errorPage));
|
|
68
|
-
} else {
|
|
69
|
-
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
|
|
70
|
-
res.end('<h1>404 Not Found</h1>The requested URL was not found on this server.');
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Read and JSON-parse the request body (available to endpoints as ctx.body). */
|
|
75
|
-
function readBody(req) {
|
|
76
|
-
return new Promise((resolve) => {
|
|
77
|
-
const chunks = [];
|
|
78
|
-
req.on('data', (c) => chunks.push(c));
|
|
79
|
-
req.on('end', () => {
|
|
80
|
-
const raw = Buffer.concat(chunks).toString('utf8');
|
|
81
|
-
if (!raw) return resolve({ raw: '', json: null });
|
|
82
|
-
let json = null;
|
|
83
|
-
try { json = JSON.parse(raw); } catch (e) { json = null; }
|
|
84
|
-
resolve({ raw, json });
|
|
85
|
-
});
|
|
86
|
-
req.on('error', () => resolve({ raw: '', json: null }));
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function handleRequest(req, res) {
|
|
91
|
-
const Response = createResponse(res);
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
// =====================================================
|
|
95
|
-
// 1. REQUEST PARSING
|
|
96
|
-
// =====================================================
|
|
97
|
-
const method = req.method;
|
|
98
|
-
const parsedUrl = new URL(req.url, 'http://localhost');
|
|
99
|
-
let uri = parsedUrl.pathname;
|
|
100
|
-
|
|
101
|
-
// Strip a leading /api, then trim trailing slash (rtrim(..., '/') ?: '/').
|
|
102
|
-
uri = uri.replace(/^\/api/, '');
|
|
103
|
-
uri = uri.replace(/\/+$/, '');
|
|
104
|
-
if (uri === '') uri = '/';
|
|
105
|
-
|
|
106
|
-
const parts = uri.split('/').filter((p) => p !== '');
|
|
107
|
-
|
|
108
|
-
for (const part of parts) {
|
|
109
|
-
if (part === '..' || part === '.') {
|
|
110
|
-
Response.error('Bad Request', 400);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// =====================================================
|
|
115
|
-
// 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
|
|
116
|
-
// =====================================================
|
|
117
|
-
let endpointFile = null;
|
|
118
|
-
let isProtected = false;
|
|
119
|
-
|
|
120
|
-
const checkParts = parts.slice();
|
|
121
|
-
const params = [];
|
|
122
|
-
|
|
123
|
-
while (checkParts.length > 0) {
|
|
124
|
-
const relativePath = checkParts.join('/') + '.js';
|
|
125
|
-
|
|
126
|
-
const publicCandidate = path.join(basePublic, relativePath);
|
|
127
|
-
if (fs.existsSync(publicCandidate)) {
|
|
128
|
-
endpointFile = publicCandidate;
|
|
129
|
-
isProtected = false;
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const protectedCandidate = path.join(baseProtected, relativePath);
|
|
134
|
-
if (fs.existsSync(protectedCandidate)) {
|
|
135
|
-
endpointFile = protectedCandidate;
|
|
136
|
-
isProtected = true;
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// No match: peel the last segment as a route param and retry.
|
|
141
|
-
params.unshift(checkParts.pop());
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// No endpoint → HTML 404 page.
|
|
145
|
-
if (!endpointFile) {
|
|
146
|
-
send404(res);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Compute the endpoint path (relative, without extension) used as the key
|
|
151
|
-
// for CUSTOM_ENDPOINT_KEYS / CUSTOM_ENDPOINT_ORIGINS.
|
|
152
|
-
const base = isProtected ? baseProtected : basePublic;
|
|
153
|
-
let endpointPath = endpointFile
|
|
154
|
-
.split(path.sep).join('/')
|
|
155
|
-
.replace(base.split(path.sep).join('/'), '')
|
|
156
|
-
.replace(/\.js$/, '')
|
|
157
|
-
.replace(/^\//, '');
|
|
158
|
-
|
|
159
|
-
// =====================================================
|
|
160
|
-
// 3. HEADERS AND CORS (only if the endpoint exists)
|
|
161
|
-
// =====================================================
|
|
162
|
-
res.setHeader('Content-Type', 'application/json; charset=UTF-8');
|
|
163
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
164
|
-
res.setHeader('X-Frame-Options', 'DENY');
|
|
165
|
-
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
166
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
167
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
|
|
168
|
-
|
|
169
|
-
const originsForEndpoint =
|
|
170
|
-
(config.CUSTOM_ENDPOINT_ORIGINS && config.CUSTOM_ENDPOINT_ORIGINS[endpointPath]) ||
|
|
171
|
-
config.GENERAL_ALLOWED_ORIGINS ||
|
|
172
|
-
[];
|
|
173
|
-
|
|
174
|
-
const origin = req.headers['origin'] || '';
|
|
175
|
-
|
|
176
|
-
if (origin !== '' && originsForEndpoint.includes(origin)) {
|
|
177
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
178
|
-
res.setHeader('Vary', 'Origin');
|
|
179
|
-
} else if (originsForEndpoint.includes('*')) {
|
|
180
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (method === 'OPTIONS') {
|
|
184
|
-
res.statusCode = 204;
|
|
185
|
-
res.end();
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Rate limit: 60 requests / 60 seconds per IP (same defaults as PHP).
|
|
190
|
-
const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
|
191
|
-
|| req.socket.remoteAddress
|
|
192
|
-
|| '';
|
|
193
|
-
RateLimiter.check(ip, 60, 60, Response, res);
|
|
194
|
-
|
|
195
|
-
// =====================================================
|
|
196
|
-
// 4. AUTHENTICATION GUARD
|
|
197
|
-
// =====================================================
|
|
198
|
-
if (isProtected) {
|
|
199
|
-
const validKey =
|
|
200
|
-
(config.CUSTOM_ENDPOINT_KEYS && config.CUSTOM_ENDPOINT_KEYS[endpointPath]) ||
|
|
201
|
-
config.GENERAL_API_KEY ||
|
|
202
|
-
'';
|
|
203
|
-
const apiKey = req.headers['x-api-key'] || '';
|
|
204
|
-
|
|
205
|
-
if (validKey === '' || !hashEquals(validKey, apiKey)) {
|
|
206
|
-
Response.error('Unauthorized. X_API_KEY is incorrect or missing', 401);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// =====================================================
|
|
211
|
-
// 5. DISPATCH
|
|
212
|
-
// =====================================================
|
|
213
|
-
const body = await readBody(req);
|
|
214
|
-
|
|
215
|
-
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
216
|
-
const handler = require(endpointFile);
|
|
217
|
-
|
|
218
|
-
const context = {
|
|
219
|
-
method, // HTTP method
|
|
220
|
-
requestParams: params, // leftover URL segments (route params)
|
|
221
|
-
Response, // request-bound Response helper
|
|
222
|
-
query: Object.fromEntries(parsedUrl.searchParams), // parsed query string
|
|
223
|
-
body: body.json, // parsed JSON body (or null)
|
|
224
|
-
rawBody: body.raw, // raw body string
|
|
225
|
-
headers: req.headers,
|
|
226
|
-
req,
|
|
227
|
-
res,
|
|
228
|
-
config,
|
|
229
|
-
CORE_PATH,
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
await handler(context);
|
|
233
|
-
|
|
234
|
-
// If the endpoint returned without sending a response, mirror PHP's
|
|
235
|
-
// implicit end of script: nothing more to do. Close if still open.
|
|
236
|
-
if (!res.writableEnded) {
|
|
237
|
-
res.end();
|
|
238
|
-
}
|
|
239
|
-
} catch (err) {
|
|
240
|
-
if (err === HALT) {
|
|
241
|
-
// A response was already sent; unwind quietly (≈ PHP exit).
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
// set_exception_handler equivalent: any real error → 500.
|
|
245
|
-
try {
|
|
246
|
-
handleException(err, config, Response);
|
|
247
|
-
} catch (inner) {
|
|
248
|
-
if (inner === HALT) return;
|
|
249
|
-
if (!res.writableEnded) {
|
|
250
|
-
res.statusCode = 500;
|
|
251
|
-
res.end(JSON.stringify({ status: 'error', message: 'Internal server error', code: 500 }));
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const HOST = process.env.HOST || '127.0.0.1';
|
|
258
|
-
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
259
|
-
|
|
260
|
-
const server = http.createServer(handleRequest);
|
|
261
|
-
|
|
262
|
-
server.listen(PORT, HOST, () => {
|
|
263
|
-
// eslint-disable-next-line no-console
|
|
264
|
-
console.log(`[backend-node] listening on http://${HOST}:${PORT} (env: ${APP_ENV})`);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
module.exports = server;
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mirror of src/backend/_core/index.php (the front controller).
|
|
5
|
+
*
|
|
6
|
+
* PHP was invoked once per request by the web server through PHP-FPM. Node is
|
|
7
|
+
* the server itself, so this file boots an HTTP listener and runs the exact same
|
|
8
|
+
* pipeline for every request:
|
|
9
|
+
*
|
|
10
|
+
* 1. request parsing (strip /api prefix, split segments, reject . / ..)
|
|
11
|
+
* 2. endpoint resolution (dynamic routing with subdirectories + route params)
|
|
12
|
+
* 3. headers + CORS
|
|
13
|
+
* 4. rate limiting
|
|
14
|
+
* 5. authentication guard for protected endpoints
|
|
15
|
+
* 6. dispatch to the matched endpoint file
|
|
16
|
+
*
|
|
17
|
+
* Endpoints keep the same authoring style as PHP: a file that receives
|
|
18
|
+
* { method, requestParams, Response, ... } and calls Response.success/error.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const http = require('http');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
const { createResponse, HALT } = require('./modules/Response');
|
|
27
|
+
const RateLimiter = require('./modules/RateLimiter');
|
|
28
|
+
const { loadConfig, handleException } = require('./init');
|
|
29
|
+
|
|
30
|
+
const CORE_PATH = __dirname;
|
|
31
|
+
|
|
32
|
+
// Load configuration once (init.php loaded it per invocation; here per process).
|
|
33
|
+
const config = loadConfig();
|
|
34
|
+
|
|
35
|
+
// Environment: mirror the display_errors toggle by picking the debug flag.
|
|
36
|
+
const APP_ENV = config.APP_ENV || 'production';
|
|
37
|
+
|
|
38
|
+
// Where to look for the 404.html generated by the static build.
|
|
39
|
+
// In a Nibula build the backend lives at out/backend/, so the site root (out/)
|
|
40
|
+
// is two levels up from _core. We default to that, so 404.html is found without
|
|
41
|
+
// any extra configuration; override with the DOCUMENT_ROOT env var if needed.
|
|
42
|
+
// (equivalent of $_SERVER['DOCUMENT_ROOT'] in the PHP front controller).
|
|
43
|
+
const DOCUMENT_ROOT = process.env.DOCUMENT_ROOT || path.join(CORE_PATH, '..', '..');
|
|
44
|
+
|
|
45
|
+
const basePublic = path.join(CORE_PATH, '..', 'api', 'public');
|
|
46
|
+
const baseProtected = path.join(CORE_PATH, '..', 'api', 'protected');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* hash_equals equivalent: constant-time string comparison, safe on unequal
|
|
50
|
+
* lengths (timingSafeEqual throws if buffers differ in length).
|
|
51
|
+
*/
|
|
52
|
+
function hashEquals(known, given) {
|
|
53
|
+
const a = Buffer.from(String(known));
|
|
54
|
+
const b = Buffer.from(String(given));
|
|
55
|
+
if (a.length !== b.length) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return crypto.timingSafeEqual(a, b);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Serve the static 404 page if available, else a plain fallback. */
|
|
62
|
+
function send404(res) {
|
|
63
|
+
res.statusCode = 404;
|
|
64
|
+
const errorPage = DOCUMENT_ROOT ? path.join(DOCUMENT_ROOT, '404.html') : '';
|
|
65
|
+
if (errorPage && fs.existsSync(errorPage)) {
|
|
66
|
+
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
|
|
67
|
+
res.end(fs.readFileSync(errorPage));
|
|
68
|
+
} else {
|
|
69
|
+
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
|
|
70
|
+
res.end('<h1>404 Not Found</h1>The requested URL was not found on this server.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read and JSON-parse the request body (available to endpoints as ctx.body). */
|
|
75
|
+
function readBody(req) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const chunks = [];
|
|
78
|
+
req.on('data', (c) => chunks.push(c));
|
|
79
|
+
req.on('end', () => {
|
|
80
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
81
|
+
if (!raw) return resolve({ raw: '', json: null });
|
|
82
|
+
let json = null;
|
|
83
|
+
try { json = JSON.parse(raw); } catch (e) { json = null; }
|
|
84
|
+
resolve({ raw, json });
|
|
85
|
+
});
|
|
86
|
+
req.on('error', () => resolve({ raw: '', json: null }));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function handleRequest(req, res) {
|
|
91
|
+
const Response = createResponse(res);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// =====================================================
|
|
95
|
+
// 1. REQUEST PARSING
|
|
96
|
+
// =====================================================
|
|
97
|
+
const method = req.method;
|
|
98
|
+
const parsedUrl = new URL(req.url, 'http://localhost');
|
|
99
|
+
let uri = parsedUrl.pathname;
|
|
100
|
+
|
|
101
|
+
// Strip a leading /api, then trim trailing slash (rtrim(..., '/') ?: '/').
|
|
102
|
+
uri = uri.replace(/^\/api/, '');
|
|
103
|
+
uri = uri.replace(/\/+$/, '');
|
|
104
|
+
if (uri === '') uri = '/';
|
|
105
|
+
|
|
106
|
+
const parts = uri.split('/').filter((p) => p !== '');
|
|
107
|
+
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
if (part === '..' || part === '.') {
|
|
110
|
+
Response.error('Bad Request', 400);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// =====================================================
|
|
115
|
+
// 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
|
|
116
|
+
// =====================================================
|
|
117
|
+
let endpointFile = null;
|
|
118
|
+
let isProtected = false;
|
|
119
|
+
|
|
120
|
+
const checkParts = parts.slice();
|
|
121
|
+
const params = [];
|
|
122
|
+
|
|
123
|
+
while (checkParts.length > 0) {
|
|
124
|
+
const relativePath = checkParts.join('/') + '.js';
|
|
125
|
+
|
|
126
|
+
const publicCandidate = path.join(basePublic, relativePath);
|
|
127
|
+
if (fs.existsSync(publicCandidate)) {
|
|
128
|
+
endpointFile = publicCandidate;
|
|
129
|
+
isProtected = false;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const protectedCandidate = path.join(baseProtected, relativePath);
|
|
134
|
+
if (fs.existsSync(protectedCandidate)) {
|
|
135
|
+
endpointFile = protectedCandidate;
|
|
136
|
+
isProtected = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// No match: peel the last segment as a route param and retry.
|
|
141
|
+
params.unshift(checkParts.pop());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// No endpoint → HTML 404 page.
|
|
145
|
+
if (!endpointFile) {
|
|
146
|
+
send404(res);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Compute the endpoint path (relative, without extension) used as the key
|
|
151
|
+
// for CUSTOM_ENDPOINT_KEYS / CUSTOM_ENDPOINT_ORIGINS.
|
|
152
|
+
const base = isProtected ? baseProtected : basePublic;
|
|
153
|
+
let endpointPath = endpointFile
|
|
154
|
+
.split(path.sep).join('/')
|
|
155
|
+
.replace(base.split(path.sep).join('/'), '')
|
|
156
|
+
.replace(/\.js$/, '')
|
|
157
|
+
.replace(/^\//, '');
|
|
158
|
+
|
|
159
|
+
// =====================================================
|
|
160
|
+
// 3. HEADERS AND CORS (only if the endpoint exists)
|
|
161
|
+
// =====================================================
|
|
162
|
+
res.setHeader('Content-Type', 'application/json; charset=UTF-8');
|
|
163
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
164
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
165
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
166
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
167
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
|
|
168
|
+
|
|
169
|
+
const originsForEndpoint =
|
|
170
|
+
(config.CUSTOM_ENDPOINT_ORIGINS && config.CUSTOM_ENDPOINT_ORIGINS[endpointPath]) ||
|
|
171
|
+
config.GENERAL_ALLOWED_ORIGINS ||
|
|
172
|
+
[];
|
|
173
|
+
|
|
174
|
+
const origin = req.headers['origin'] || '';
|
|
175
|
+
|
|
176
|
+
if (origin !== '' && originsForEndpoint.includes(origin)) {
|
|
177
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
178
|
+
res.setHeader('Vary', 'Origin');
|
|
179
|
+
} else if (originsForEndpoint.includes('*')) {
|
|
180
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (method === 'OPTIONS') {
|
|
184
|
+
res.statusCode = 204;
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Rate limit: 60 requests / 60 seconds per IP (same defaults as PHP).
|
|
190
|
+
const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
|
191
|
+
|| req.socket.remoteAddress
|
|
192
|
+
|| '';
|
|
193
|
+
RateLimiter.check(ip, 60, 60, Response, res);
|
|
194
|
+
|
|
195
|
+
// =====================================================
|
|
196
|
+
// 4. AUTHENTICATION GUARD
|
|
197
|
+
// =====================================================
|
|
198
|
+
if (isProtected) {
|
|
199
|
+
const validKey =
|
|
200
|
+
(config.CUSTOM_ENDPOINT_KEYS && config.CUSTOM_ENDPOINT_KEYS[endpointPath]) ||
|
|
201
|
+
config.GENERAL_API_KEY ||
|
|
202
|
+
'';
|
|
203
|
+
const apiKey = req.headers['x-api-key'] || '';
|
|
204
|
+
|
|
205
|
+
if (validKey === '' || !hashEquals(validKey, apiKey)) {
|
|
206
|
+
Response.error('Unauthorized. X_API_KEY is incorrect or missing', 401);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// =====================================================
|
|
211
|
+
// 5. DISPATCH
|
|
212
|
+
// =====================================================
|
|
213
|
+
const body = await readBody(req);
|
|
214
|
+
|
|
215
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
216
|
+
const handler = require(endpointFile);
|
|
217
|
+
|
|
218
|
+
const context = {
|
|
219
|
+
method, // HTTP method
|
|
220
|
+
requestParams: params, // leftover URL segments (route params)
|
|
221
|
+
Response, // request-bound Response helper
|
|
222
|
+
query: Object.fromEntries(parsedUrl.searchParams), // parsed query string
|
|
223
|
+
body: body.json, // parsed JSON body (or null)
|
|
224
|
+
rawBody: body.raw, // raw body string
|
|
225
|
+
headers: req.headers,
|
|
226
|
+
req,
|
|
227
|
+
res,
|
|
228
|
+
config,
|
|
229
|
+
CORE_PATH,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await handler(context);
|
|
233
|
+
|
|
234
|
+
// If the endpoint returned without sending a response, mirror PHP's
|
|
235
|
+
// implicit end of script: nothing more to do. Close if still open.
|
|
236
|
+
if (!res.writableEnded) {
|
|
237
|
+
res.end();
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err === HALT) {
|
|
241
|
+
// A response was already sent; unwind quietly (≈ PHP exit).
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// set_exception_handler equivalent: any real error → 500.
|
|
245
|
+
try {
|
|
246
|
+
handleException(err, config, Response);
|
|
247
|
+
} catch (inner) {
|
|
248
|
+
if (inner === HALT) return;
|
|
249
|
+
if (!res.writableEnded) {
|
|
250
|
+
res.statusCode = 500;
|
|
251
|
+
res.end(JSON.stringify({ status: 'error', message: 'Internal server error', code: 500 }));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
258
|
+
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
259
|
+
|
|
260
|
+
const server = http.createServer(handleRequest);
|
|
261
|
+
|
|
262
|
+
server.listen(PORT, HOST, () => {
|
|
263
|
+
// eslint-disable-next-line no-console
|
|
264
|
+
console.log(`[backend-node] listening on http://${HOST}:${PORT} (env: ${APP_ENV})`);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
module.exports = server;
|
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Mirror of src/backend/_core/init.php
|
|
5
|
-
*
|
|
6
|
-
* init.php did three things:
|
|
7
|
-
* 1. registered a global exception handler that turns any throwable into a
|
|
8
|
-
* 500 JSON response (verbose in debug, generic in production);
|
|
9
|
-
* 2. registered an error handler that promoted PHP warnings to exceptions;
|
|
10
|
-
* 3. loaded config.php.
|
|
11
|
-
*
|
|
12
|
-
* In Node there is no per-request global handler, so the exception-to-500
|
|
13
|
-
* translation lives in the front controller (index.js), which calls
|
|
14
|
-
* handleException() below. Here we expose config loading and that helper.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Load configuration. dirname(__dirname) in PHP pointed at the folder holding
|
|
22
|
-
* config.php (the backend root). Here that is the parent of _core.
|
|
23
|
-
* If config.js is missing (e.g. fresh clone) we fall back to example.config.js,
|
|
24
|
-
* mirroring the "copy example.config to config" guidance.
|
|
25
|
-
*/
|
|
26
|
-
function loadConfig() {
|
|
27
|
-
const backendRoot = path.join(__dirname, '..');
|
|
28
|
-
const configPath = path.join(backendRoot, 'config.js');
|
|
29
|
-
const examplePath = path.join(backendRoot, 'example.config.js');
|
|
30
|
-
|
|
31
|
-
if (fs.existsSync(configPath)) {
|
|
32
|
-
return require(configPath);
|
|
33
|
-
}
|
|
34
|
-
return require(examplePath);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Turn any thrown error into a 500 response, mirroring set_exception_handler.
|
|
39
|
-
* @param {Error} exception
|
|
40
|
-
* @param {object} config
|
|
41
|
-
* @param {object} Response request-bound helper
|
|
42
|
-
*/
|
|
43
|
-
function handleException(exception, config, Response) {
|
|
44
|
-
const isDebug = (config.APP_ENV || 'production') !== 'production';
|
|
45
|
-
Response.error(
|
|
46
|
-
isDebug ? exception.message : 'Internal server error',
|
|
47
|
-
500,
|
|
48
|
-
isDebug ? { stack: exception.stack } : null
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
module.exports = { loadConfig, handleException };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mirror of src/backend/_core/init.php
|
|
5
|
+
*
|
|
6
|
+
* init.php did three things:
|
|
7
|
+
* 1. registered a global exception handler that turns any throwable into a
|
|
8
|
+
* 500 JSON response (verbose in debug, generic in production);
|
|
9
|
+
* 2. registered an error handler that promoted PHP warnings to exceptions;
|
|
10
|
+
* 3. loaded config.php.
|
|
11
|
+
*
|
|
12
|
+
* In Node there is no per-request global handler, so the exception-to-500
|
|
13
|
+
* translation lives in the front controller (index.js), which calls
|
|
14
|
+
* handleException() below. Here we expose config loading and that helper.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load configuration. dirname(__dirname) in PHP pointed at the folder holding
|
|
22
|
+
* config.php (the backend root). Here that is the parent of _core.
|
|
23
|
+
* If config.js is missing (e.g. fresh clone) we fall back to example.config.js,
|
|
24
|
+
* mirroring the "copy example.config to config" guidance.
|
|
25
|
+
*/
|
|
26
|
+
function loadConfig() {
|
|
27
|
+
const backendRoot = path.join(__dirname, '..');
|
|
28
|
+
const configPath = path.join(backendRoot, 'config.js');
|
|
29
|
+
const examplePath = path.join(backendRoot, 'example.config.js');
|
|
30
|
+
|
|
31
|
+
if (fs.existsSync(configPath)) {
|
|
32
|
+
return require(configPath);
|
|
33
|
+
}
|
|
34
|
+
return require(examplePath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turn any thrown error into a 500 response, mirroring set_exception_handler.
|
|
39
|
+
* @param {Error} exception
|
|
40
|
+
* @param {object} config
|
|
41
|
+
* @param {object} Response request-bound helper
|
|
42
|
+
*/
|
|
43
|
+
function handleException(exception, config, Response) {
|
|
44
|
+
const isDebug = (config.APP_ENV || 'production') !== 'production';
|
|
45
|
+
Response.error(
|
|
46
|
+
isDebug ? exception.message : 'Internal server error',
|
|
47
|
+
500,
|
|
48
|
+
isDebug ? { stack: exception.stack } : null
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { loadConfig, handleException };
|