nibula 1.2.4 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eleventy.js +81 -81
- package/.eleventyignore +3 -3
- package/.github/workflows/publish.yml +37 -0
- package/CHANGELOG.md +166 -148
- package/LICENSE +169 -169
- package/NOTICE +4 -4
- package/README.md +120 -123
- package/bin/create.js +507 -507
- package/bin/nibula.js +317 -305
- package/docs/Assistant CLI.md +65 -65
- package/docs/Backend.md +295 -295
- package/docs/Components.md +84 -84
- package/docs/Creating pages.md +64 -64
- package/docs/Deploy.md +189 -189
- package/docs/Head and SEO.md +138 -138
- package/docs/Javascript.md +50 -50
- package/docs/Styling with SCSS.md +141 -141
- package/nginx.conf +75 -75
- package/package.json +1 -1
- package/src/backend/.htaccess +6 -6
- package/src/backend/_core/composer.json +5 -5
- package/src/backend/_core/composer.lock +492 -492
- package/src/backend/_core/index.js +267 -267
- package/src/backend/_core/index.php +147 -147
- package/src/backend/_core/init.js +52 -52
- package/src/backend/_core/init.php +33 -33
- package/src/backend/_core/modules/RateLimiter.js +58 -58
- package/src/backend/_core/modules/RateLimiter.php +30 -30
- package/src/backend/_core/modules/Response.js +59 -59
- package/src/backend/_core/modules/Response.php +48 -48
- package/src/backend/api/protected/example-protected.js +23 -23
- package/src/backend/api/protected/example-protected.php +16 -16
- package/src/backend/api/public/example-public.js +24 -24
- package/src/backend/api/public/example-public.php +16 -16
- package/src/backend/backend-node.service.example +30 -30
- package/src/backend/database/Database.js +46 -46
- package/src/backend/database/Database.php +23 -23
- package/src/backend/example.config.js +37 -37
- package/src/backend/example.config.php +27 -27
- package/src/backend/package.json +18 -18
- package/src/backend/web.config +16 -16
- package/src/frontend/.htaccess +51 -51
- package/src/frontend/404.njk +17 -17
- package/src/frontend/assets/brand/favicon.svg +54 -54
- package/src/frontend/assets/brand/logo.svg +54 -54
- package/src/frontend/components/global/footer.njk +25 -25
- package/src/frontend/components/global/header.njk +6 -6
- package/src/frontend/components/welcome.njk +114 -114
- package/src/frontend/data/site.json +48 -48
- package/src/frontend/index.njk +8 -8
- package/src/frontend/js/modules/exampleModule.js +2 -2
- package/src/frontend/js/pages/404.js +6 -6
- package/src/frontend/js/pages/homepage.js +6 -6
- package/src/frontend/layouts/base.njk +132 -132
- package/src/frontend/layouts/page-components.njk +13 -13
- package/src/frontend/llms.njk +17 -17
- package/src/frontend/robots.njk +7 -7
- package/src/frontend/scss/modules/_animations.scss +24 -24
- package/src/frontend/scss/modules/_footer.scss +27 -27
- package/src/frontend/scss/modules/_global.scss +47 -47
- package/src/frontend/scss/modules/_header.scss +27 -27
- package/src/frontend/scss/modules/_mobile.scss +29 -29
- package/src/frontend/scss/modules/_root.scss +34 -34
- package/src/frontend/scss/modules/_typography.scss +14 -14
- package/src/frontend/scss/modules/frameworks/_bootstrap.scss +109 -109
- package/src/frontend/scss/modules/frameworks/_bulma.scss +108 -108
- package/src/frontend/scss/modules/frameworks/_foundation.scss +138 -139
- package/src/frontend/scss/modules/frameworks/_uikit.scss +109 -109
- package/src/frontend/scss/pages/404.scss +27 -27
- package/src/frontend/scss/pages/homepage.scss +22 -22
- package/src/frontend/sitemap.njk +17 -17
- package/src/frontend/ts/modules/exampleModule.ts +2 -2
- package/src/frontend/ts/pages/404.ts +6 -6
- package/src/frontend/ts/pages/homepage.ts +6 -6
- package/src/frontend/web.config +55 -55
- package/tools/config/messages.json +3 -1
- package/tools/res/templates/template.js +6 -6
- package/tools/res/templates/template.njk +8 -8
- package/tools/res/templates/template.scss +22 -22
- package/tools/res/templates/template.ts +6 -6
- package/tsconfig.json +24 -24
|
@@ -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;
|