nibula 2.4.4 → 2.4.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/.eleventy.js CHANGED
@@ -87,14 +87,9 @@ module.exports = function (eleventyConfig) {
87
87
  // Passthrough copy
88
88
  // ---------------------------------------------------------------------------
89
89
 
90
+ // Prism syntax highlighting theme
90
91
  eleventyConfig.addPassthroughCopy({
91
- // Prism syntax highlighting theme
92
92
  "node_modules/prismjs/themes/prism-tomorrow.css": "css/prism.css",
93
-
94
- // Bootstrap
95
- "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js":
96
- "js/bootstrap.bundle.min.js",
97
- "node_modules/bootstrap-icons/font/fonts": "css/fonts",
98
93
  });
99
94
 
100
95
  // Project assets
@@ -107,11 +102,11 @@ module.exports = function (eleventyConfig) {
107
102
  "src/frontend/hosting/web.config": "web.config",
108
103
  });
109
104
 
110
- // node_modules dependencies
105
+ // CSS frameworks — uncomment the one you are using
106
+ // prettier-ignore
111
107
  eleventyConfig.addPassthroughCopy({
112
108
  // Bootstrap
113
- "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js":
114
- "js/bootstrap.bundle.min.js",
109
+ "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js": "js/bootstrap.bundle.min.js",
115
110
  "node_modules/bootstrap-icons/font/fonts": "css/fonts",
116
111
 
117
112
  // Foundation
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to Nibula are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.4.6] - 2026-08-29
9
+
10
+ ### Changed
11
+
12
+ - Restored `404.scss` **.not-found** selector to center the not found text
13
+ - Removed all comments in backend files
14
+
15
+ ## [2.4.5] - 2026-08-26
16
+
17
+ ### Fixed
18
+ - **A project scaffolded with 2.4.4 wouldn't start.** The passthrough entry for
19
+ the CSS framework spanned two lines, so commenting out the marker line left the
20
+ destination string orphaned inside the object and `.eleventy.js` failed to
21
+ parse. Every key-value pair now sits on one line, with `// prettier-ignore`
22
+ above the block so a formatter can't split them again.
23
+ - The Bootstrap passthrough was declared twice — once in the block added for the
24
+ Prism theme and once among the framework markers — so the scaffolder commented
25
+ out both copies and produced two broken entries instead of one.
26
+
8
27
  ## [2.4.4] - 2026-08-26
9
28
 
10
29
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nibula",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "A beginner-friendly static site generator built on Eleventy that stays close to vanilla HTML, CSS and JS — with a page-management CLI, an optional PHP backend, and SEO files generated for you.",
5
5
  "keywords": [
6
6
  "11ty",
@@ -1,267 +1,230 @@
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');
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const crypto = require("crypto");
7
+
8
+ const { createResponse, HALT } = require("./modules/Response");
9
+ const RateLimiter = require("./modules/RateLimiter");
10
+ const { loadConfig, handleException } = require("./init");
29
11
 
30
12
  const CORE_PATH = __dirname;
31
13
 
32
- // Load configuration once (init.php loaded it per invocation; here per process).
33
14
  const config = loadConfig();
34
15
 
35
- // Environment: mirror the display_errors toggle by picking the debug flag.
36
- const APP_ENV = config.APP_ENV || 'production';
16
+ const APP_ENV = config.APP_ENV || "production";
37
17
 
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, '..', '..');
18
+ const DOCUMENT_ROOT =
19
+ process.env.DOCUMENT_ROOT || path.join(CORE_PATH, "..", "..");
44
20
 
45
- const basePublic = path.join(CORE_PATH, '..', 'api', 'public');
46
- const baseProtected = path.join(CORE_PATH, '..', 'api', 'protected');
21
+ const basePublic = path.join(CORE_PATH, "..", "api", "public");
22
+ const baseProtected = path.join(CORE_PATH, "..", "api", "protected");
47
23
 
48
- /**
49
- * hash_equals equivalent: constant-time string comparison, safe on unequal
50
- * lengths (timingSafeEqual throws if buffers differ in length).
51
- */
52
24
  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);
25
+ const a = Buffer.from(String(known));
26
+ const b = Buffer.from(String(given));
27
+ if (a.length !== b.length) {
28
+ return false;
29
+ }
30
+ return crypto.timingSafeEqual(a, b);
59
31
  }
60
32
 
61
- /** Serve the static 404 page if available, else a plain fallback. */
62
33
  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
- }
34
+ res.statusCode = 404;
35
+ const errorPage = DOCUMENT_ROOT ? path.join(DOCUMENT_ROOT, "404.html") : "";
36
+ if (errorPage && fs.existsSync(errorPage)) {
37
+ res.setHeader("Content-Type", "text/html; charset=UTF-8");
38
+ res.end(fs.readFileSync(errorPage));
39
+ } else {
40
+ res.setHeader("Content-Type", "text/html; charset=UTF-8");
41
+ res.end(
42
+ "<h1>404 Not Found</h1>The requested URL was not found on this server.",
43
+ );
44
+ }
72
45
  }
73
46
 
74
- /** Read and JSON-parse the request body (available to endpoints as ctx.body). */
75
47
  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 }));
48
+ return new Promise((resolve) => {
49
+ const chunks = [];
50
+ req.on("data", (c) => chunks.push(c));
51
+ req.on("end", () => {
52
+ const raw = Buffer.concat(chunks).toString("utf8");
53
+ if (!raw) return resolve({ raw: "", json: null });
54
+ let json = null;
55
+ try {
56
+ json = JSON.parse(raw);
57
+ } catch (e) {
58
+ json = null;
59
+ }
60
+ resolve({ raw, json });
87
61
  });
62
+ req.on("error", () => resolve({ raw: "", json: null }));
63
+ });
88
64
  }
89
65
 
90
66
  async function handleRequest(req, res) {
91
- const Response = createResponse(res);
67
+ const Response = createResponse(res);
68
+
69
+ try {
70
+ const method = req.method;
71
+ const parsedUrl = new URL(req.url, "http://localhost");
72
+ let uri = parsedUrl.pathname;
73
+
74
+ uri = uri.replace(/^\/api/, "");
75
+ uri = uri.replace(/\/+$/, "");
76
+ if (uri === "") uri = "/";
77
+
78
+ const parts = uri.split("/").filter((p) => p !== "");
79
+
80
+ for (const part of parts) {
81
+ if (part === ".." || part === ".") {
82
+ Response.error("Bad Request", 400);
83
+ }
84
+ }
85
+
86
+ let endpointFile = null;
87
+ let isProtected = false;
88
+
89
+ const checkParts = parts.slice();
90
+ const params = [];
91
+
92
+ while (checkParts.length > 0) {
93
+ const relativePath = checkParts.join("/") + ".js";
94
+
95
+ const publicCandidate = path.join(basePublic, relativePath);
96
+ if (fs.existsSync(publicCandidate)) {
97
+ endpointFile = publicCandidate;
98
+ isProtected = false;
99
+ break;
100
+ }
92
101
 
102
+ const protectedCandidate = path.join(baseProtected, relativePath);
103
+ if (fs.existsSync(protectedCandidate)) {
104
+ endpointFile = protectedCandidate;
105
+ isProtected = true;
106
+ break;
107
+ }
108
+
109
+ params.unshift(checkParts.pop());
110
+ }
111
+
112
+ if (!endpointFile) {
113
+ send404(res);
114
+ return;
115
+ }
116
+
117
+ const base = isProtected ? baseProtected : basePublic;
118
+ let endpointPath = endpointFile
119
+ .split(path.sep)
120
+ .join("/")
121
+ .replace(base.split(path.sep).join("/"), "")
122
+ .replace(/\.js$/, "")
123
+ .replace(/^\//, "");
124
+
125
+ res.setHeader("Content-Type", "application/json; charset=UTF-8");
126
+ res.setHeader("X-Content-Type-Options", "nosniff");
127
+ res.setHeader("X-Frame-Options", "DENY");
128
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
129
+ res.setHeader(
130
+ "Access-Control-Allow-Methods",
131
+ "GET, POST, PUT, PATCH, DELETE, OPTIONS",
132
+ );
133
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Api-Key");
134
+
135
+ const originsForEndpoint =
136
+ (config.CUSTOM_ENDPOINT_ORIGINS &&
137
+ config.CUSTOM_ENDPOINT_ORIGINS[endpointPath]) ||
138
+ config.GENERAL_ALLOWED_ORIGINS ||
139
+ [];
140
+
141
+ const origin = req.headers["origin"] || "";
142
+
143
+ if (origin !== "" && originsForEndpoint.includes(origin)) {
144
+ res.setHeader("Access-Control-Allow-Origin", origin);
145
+ res.setHeader("Vary", "Origin");
146
+ } else if (originsForEndpoint.includes("*")) {
147
+ res.setHeader("Access-Control-Allow-Origin", "*");
148
+ }
149
+
150
+ if (method === "OPTIONS") {
151
+ res.statusCode = 204;
152
+ res.end();
153
+ return;
154
+ }
155
+
156
+ // Rate limit: 60 requests / 60 seconds per IP
157
+ const ip =
158
+ (req.headers["x-forwarded-for"] || "").split(",")[0].trim() ||
159
+ req.socket.remoteAddress ||
160
+ "";
161
+ RateLimiter.check(ip, 60, 60, Response, res);
162
+
163
+ if (isProtected) {
164
+ const validKey =
165
+ (config.CUSTOM_ENDPOINT_KEYS &&
166
+ config.CUSTOM_ENDPOINT_KEYS[endpointPath]) ||
167
+ config.GENERAL_API_KEY ||
168
+ "";
169
+ const apiKey = req.headers["x-api-key"] || "";
170
+
171
+ if (validKey === "" || !hashEquals(validKey, apiKey)) {
172
+ Response.error("Unauthorized. X_API_KEY is incorrect or missing", 403);
173
+ }
174
+ }
175
+
176
+ const handler = require(endpointFile);
177
+
178
+ const context = {
179
+ method, // HTTP method
180
+ requestParams: params, // leftover URL segments (route params)
181
+ Response, // request-bound Response helper
182
+ query: Object.fromEntries(parsedUrl.searchParams), // parsed query string
183
+ body: body.json, // parsed JSON body (or null)
184
+ rawBody: body.raw, // raw body string
185
+ headers: req.headers,
186
+ req,
187
+ res,
188
+ config,
189
+ CORE_PATH,
190
+ };
191
+
192
+ await handler(context);
193
+
194
+ if (!res.writableEnded) {
195
+ res.end();
196
+ }
197
+ } catch (err) {
198
+ if (err === HALT) {
199
+ return;
200
+ }
93
201
  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', 403);
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
- }
202
+ handleException(err, config, Response);
203
+ } catch (inner) {
204
+ if (inner === HALT) return;
205
+ if (!res.writableEnded) {
206
+ res.statusCode = 500;
207
+ res.end(
208
+ JSON.stringify({
209
+ status: "error",
210
+ message: "Internal server error",
211
+ code: 500,
212
+ }),
213
+ );
214
+ }
254
215
  }
216
+ }
255
217
  }
256
218
 
257
- const HOST = process.env.HOST || '127.0.0.1';
258
- const PORT = parseInt(process.env.PORT || '3000', 10);
219
+ const HOST = process.env.HOST || "127.0.0.1";
220
+ const PORT = parseInt(process.env.PORT || "3000", 10);
259
221
 
260
222
  const server = http.createServer(handleRequest);
261
223
 
262
224
  server.listen(PORT, HOST, () => {
263
- // eslint-disable-next-line no-console
264
- console.log(`[backend-node] listening on http://${HOST}:${PORT} (env: ${APP_ENV})`);
225
+ console.log(
226
+ `[backend-node] listening on http://${HOST}:${PORT} (env: ${APP_ENV})`,
227
+ );
265
228
  });
266
229
 
267
230
  module.exports = server;
@@ -5,16 +5,9 @@ declare(strict_types=1);
5
5
  define('CORE_ACCESS', true);
6
6
  define('CORE_PATH', __DIR__);
7
7
 
8
- /**
9
- * Load dependencies and initial configuration.
10
- */
11
8
  require_once __DIR__ . '/init.php';
12
9
  require_once __DIR__ . '/modules/Response.php';
13
10
 
14
- // =====================================================
15
- // 1. REQUEST PARSING
16
- // =====================================================
17
-
18
11
  $method = $_SERVER['REQUEST_METHOD'];
19
12
  $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
20
13
 
@@ -27,26 +20,15 @@ foreach ($parts as $part) {
27
20
  }
28
21
  }
29
22
 
30
- // =====================================================
31
- // 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
32
- // =====================================================
33
-
34
23
  $basePublic = __DIR__ . '/../api/public/';
35
24
  $baseProtected = __DIR__ . '/../api/protected/';
36
25
 
37
26
  $endpointFile = null;
38
27
  $isProtected = false;
39
28
  $requestParams = [];
40
-
41
- // Temporary variables for the lookup loop
42
29
  $checkParts = $parts;
43
30
  $params = [];
44
31
 
45
- /**
46
- * Dynamic routing logic: find the deepest matching endpoint file.
47
- * At each iteration, the last URL segment is peeled off and stored
48
- * as a route parameter until a matching file is found.
49
- */
50
32
  while (count($checkParts) > 0) {
51
33
  $relativePath = implode('/', $checkParts) . '.php';
52
34
 
@@ -57,30 +39,19 @@ while (count($checkParts) > 0) {
57
39
  break;
58
40
  }
59
41
 
60
- // Then check whether this is a protected route
61
42
  if (file_exists($baseProtected . $relativePath)) {
62
43
  $endpointFile = $baseProtected . $relativePath;
63
44
  $isProtected = true;
64
45
  break;
65
46
  }
66
47
 
67
- // No file matched: treat the last segment as a route parameter and try again
68
48
  array_unshift($params, array_pop($checkParts));
69
49
  }
70
50
 
71
- /**
72
- * IF THE ENDPOINT DOES NOT EXIST, RETURN AN HTML 404 PAGE.
73
- */
74
51
  if (!$endpointFile) {
75
52
  http_response_code(404);
76
53
  header_remove('WWW-Authenticate');
77
54
 
78
- /**
79
- * In a Nibula build the backend lives at out/backend/, so the site root
80
- * (out/) is two levels up from _core. DOCUMENT_ROOT is preferred when it
81
- * resolves, but on shared hosting it often points elsewhere, so the
82
- * relative path is used as a fallback. Same resolution as index.js.
83
- */
84
55
  $documentRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
85
56
  $errorPage = $documentRoot . '/404.html';
86
57
 
@@ -104,10 +75,6 @@ $base = str_replace('\\', '/', $base);
104
75
  $endpointPath = str_replace('\\', '/', $endpointFile);
105
76
  $endpointPath = preg_replace('#\.php$#', '', str_replace($base, '', $endpointPath));
106
77
 
107
- // =====================================================
108
- // 3. HEADERS AND CORS (Only if the endpoint exists)
109
- // =====================================================
110
-
111
78
  header('Content-Type: application/json; charset=UTF-8');
112
79
  header_remove('WWW-Authenticate');
113
80
  header('X-Content-Type-Options: nosniff');
@@ -137,10 +104,6 @@ if ($method === 'OPTIONS') {
137
104
  require_once __DIR__ . '/modules/RateLimiter.php';
138
105
  RateLimiter::check($_SERVER['REMOTE_ADDR'] ?? '', 60, 60);
139
106
 
140
- // =====================================================
141
- // 4. AUTHENTICATION GUARD
142
- // =====================================================
143
-
144
107
  if ($isProtected) {
145
108
  $validKey = $config['CUSTOM_ENDPOINT_KEYS'][$endpointPath] ?? $config['GENERAL_API_KEY'] ?? '';
146
109
  $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
@@ -150,12 +113,6 @@ if ($isProtected) {
150
113
  }
151
114
  }
152
115
 
153
- // =====================================================
154
- // 5. DISPATCH
155
- // =====================================================
156
-
157
- // Parameters are the remaining URL segments not consumed during endpoint resolution
158
116
  $requestParams = $params;
159
117
 
160
- // Load and execute the matched endpoint file
161
118
  require $endpointFile;
@@ -1,37 +1,17 @@
1
- 'use strict';
1
+ "use strict";
2
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');
3
+ const fs = require("fs");
4
+ const path = require("path");
19
5
 
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
6
  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');
7
+ const backendRoot = path.join(__dirname, "..");
8
+ const configPath = path.join(backendRoot, "config.js");
9
+ const examplePath = path.join(backendRoot, "example.config.js");
30
10
 
31
- if (fs.existsSync(configPath)) {
32
- return require(configPath);
33
- }
34
- return require(examplePath);
11
+ if (fs.existsSync(configPath)) {
12
+ return require(configPath);
13
+ }
14
+ return require(examplePath);
35
15
  }
36
16
 
37
17
  /**
@@ -41,12 +21,12 @@ function loadConfig() {
41
21
  * @param {object} Response request-bound helper
42
22
  */
43
23
  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
- );
24
+ const isDebug = (config.APP_ENV || "production") !== "production";
25
+ Response.error(
26
+ isDebug ? exception.message : "Internal server error",
27
+ 500,
28
+ isDebug ? { stack: exception.stack } : null,
29
+ );
50
30
  }
51
31
 
52
32
  module.exports = { loadConfig, handleException };
@@ -5,7 +5,6 @@ declare(strict_types=1);
5
5
  require_once __DIR__ . '/vendor/autoload.php';
6
6
  require_once __DIR__ . '/modules/Response.php';
7
7
 
8
- // --- GESTORE GLOBALE ERRORI E ECCEZIONI ---
9
8
  set_exception_handler(function ($exception) use (&$config) {
10
9
  $isDebug = ($config['APP_ENV'] ?? 'production') !== 'production';
11
10
  Response::error(
@@ -20,11 +19,8 @@ set_error_handler(function ($severity, $message, $file, $line) {
20
19
  throw new ErrorException($message, 0, $severity, $file, $line);
21
20
  });
22
21
 
23
- // --- CARICAMENTO CONFIGURAZIONE ---
24
- // dirname(__DIR__) punta alla cartella /api/ dove ora si trova config.php
25
22
  $config = require dirname(__DIR__) . '/config.php';
26
23
 
27
- // --- CONFIGURAZIONE AMBIENTE ---
28
24
  if (($config['APP_ENV'] ?? 'production') === 'production') {
29
25
  ini_set('display_errors', '0');
30
26
  error_reporting(0);
@@ -1,58 +1,47 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- /**
4
- * Mirror of src/backend/_core/modules/RateLimiter.php
5
- *
6
- * Same algorithm and same on-disk format: one JSON file per IP under ../../cache,
7
- * holding an array of request timestamps. Old timestamps outside the window are
8
- * pruned, the current one appended, and if the count exceeds the limit a 429 is
9
- * returned (with a Retry-After header) through the request-bound Response helper.
10
- */
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
- const crypto = require('crypto');
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
15
6
 
16
7
  class RateLimiter {
17
- /**
18
- * @param {string} ip
19
- * @param {number} maxRequests
20
- * @param {number} windowSeconds
21
- * @param {object} Response request-bound helper from createResponse(res)
22
- * @param {object} res raw http response (to set Retry-After)
23
- */
24
- static check(ip, maxRequests = 60, windowSeconds = 60, Response, res) {
25
- const cacheDir = path.join(__dirname, '..', '..', 'cache');
26
-
27
- if (!fs.existsSync(cacheDir)) {
28
- fs.mkdirSync(cacheDir, { recursive: true, mode: 0o755 });
29
- }
30
-
31
- const hash = crypto.createHash('md5').update(ip).digest('hex');
32
- const cacheFile = path.join(cacheDir, 'rl_' + hash + '.json');
33
- const now = Math.floor(Date.now() / 1000);
34
- let data = [];
35
-
36
- if (fs.existsSync(cacheFile)) {
37
- try {
38
- data = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) || [];
39
- } catch (e) {
40
- data = [];
41
- }
42
- }
43
-
44
- data = data.filter((ts) => ts > (now - windowSeconds));
45
- data.push(now);
46
-
47
- // LOCK_EX equivalent: an exclusive write. Node's writeFileSync is atomic
48
- // enough for this single-process model.
49
- fs.writeFileSync(cacheFile, JSON.stringify(data));
50
-
51
- if (data.length > maxRequests) {
52
- res.setHeader('Retry-After', String(windowSeconds));
53
- Response.error('Too Many Requests', 429);
54
- }
8
+ /**
9
+ * @param {string} ip
10
+ * @param {number} maxRequests
11
+ * @param {number} windowSeconds
12
+ * @param {object} Response request-bound helper from createResponse(res)
13
+ * @param {object} res raw http response (to set Retry-After)
14
+ */
15
+ static check(ip, maxRequests = 60, windowSeconds = 60, Response, res) {
16
+ const cacheDir = path.join(__dirname, "..", "..", "cache");
17
+
18
+ if (!fs.existsSync(cacheDir)) {
19
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o755 });
20
+ }
21
+
22
+ const hash = crypto.createHash("md5").update(ip).digest("hex");
23
+ const cacheFile = path.join(cacheDir, "rl_" + hash + ".json");
24
+ const now = Math.floor(Date.now() / 1000);
25
+ let data = [];
26
+
27
+ if (fs.existsSync(cacheFile)) {
28
+ try {
29
+ data = JSON.parse(fs.readFileSync(cacheFile, "utf8")) || [];
30
+ } catch (e) {
31
+ data = [];
32
+ }
33
+ }
34
+
35
+ data = data.filter((ts) => ts > now - windowSeconds);
36
+ data.push(now);
37
+
38
+ fs.writeFileSync(cacheFile, JSON.stringify(data));
39
+
40
+ if (data.length > maxRequests) {
41
+ res.setHeader("Retry-After", String(windowSeconds));
42
+ Response.error("Too Many Requests", 429);
55
43
  }
44
+ }
56
45
  }
57
46
 
58
47
  module.exports = RateLimiter;
@@ -1,59 +1,46 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- /**
4
- * Mirror of src/backend/_core/modules/Response.php
5
- *
6
- * In PHP the Response helper writes JSON and calls exit() to stop execution.
7
- * Node has no per-request exit, so each method writes to the response and then
8
- * throws the HALT sentinel. The front controller catches HALT and stops the
9
- * dispatch, reproducing the same "the endpoint stops here" behaviour.
10
- *
11
- * A fresh, response-bound helper is created per request via createResponse(res)
12
- * so overlapping async requests never clash (PHP was process-per-request).
13
- */
14
-
15
- // Sentinel thrown to unwind execution after a response has been sent (≈ PHP exit).
16
- const HALT = Symbol('RESPONSE_HALT');
3
+ const HALT = Symbol("RESPONSE_HALT");
17
4
 
18
5
  function jsonEncode(obj) {
19
- // JSON.stringify already leaves unicode unescaped and does not escape slashes,
20
- // matching PHP's JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES.
21
- return JSON.stringify(obj);
6
+ return JSON.stringify(obj);
22
7
  }
23
8
 
24
9
  function createResponse(res) {
25
- return {
26
- success(data = null, code = 200) {
27
- res.statusCode = code;
28
- res.setHeader('Content-Type', 'application/json; charset=UTF-8');
29
- res.end(jsonEncode({
30
- status: 'success',
31
- data: data,
32
- }));
33
- throw HALT;
34
- },
10
+ return {
11
+ success(data = null, code = 200) {
12
+ res.statusCode = code;
13
+ res.setHeader("Content-Type", "application/json; charset=UTF-8");
14
+ res.end(
15
+ jsonEncode({
16
+ status: "success",
17
+ data: data,
18
+ }),
19
+ );
20
+ throw HALT;
21
+ },
35
22
 
36
- error(message, code = 400, details = null) {
37
- res.statusCode = code;
38
- res.setHeader('Content-Type', 'application/json; charset=UTF-8');
39
- const body = {
40
- status: 'error',
41
- message: message,
42
- code: code,
43
- };
44
- if (details !== null) {
45
- body.details = details;
46
- }
47
- res.end(jsonEncode(body));
48
- throw HALT;
49
- },
23
+ error(message, code = 400, details = null) {
24
+ res.statusCode = code;
25
+ res.setHeader("Content-Type", "application/json; charset=UTF-8");
26
+ const body = {
27
+ status: "error",
28
+ message: message,
29
+ code: code,
30
+ };
31
+ if (details !== null) {
32
+ body.details = details;
33
+ }
34
+ res.end(jsonEncode(body));
35
+ throw HALT;
36
+ },
50
37
 
51
- noContent() {
52
- res.statusCode = 204;
53
- res.end();
54
- throw HALT;
55
- },
56
- };
38
+ noContent() {
39
+ res.statusCode = 204;
40
+ res.end();
41
+ throw HALT;
42
+ },
43
+ };
57
44
  }
58
45
 
59
46
  module.exports = { createResponse, HALT };
@@ -1,23 +1,16 @@
1
- 'use strict';
2
-
3
- /**
4
- * Mirror of src/backend/api/protected/example-protected.php
5
- *
6
- * Protected endpoint: the API key check (X-Api-Key header) runs automatically in
7
- * the front controller before this file is loaded, so here we just handle logic.
8
- */
1
+ "use strict";
9
2
 
10
3
  module.exports = ({ method, requestParams, Response }) => {
11
- if (method !== 'GET') {
12
- Response.error('Method not allowed', 405);
13
- }
4
+ if (method !== "GET") {
5
+ Response.error("Method not allowed", 405);
6
+ }
14
7
 
15
- //
16
- // Your endpoint logic here. You can access route parameters in requestParams
17
- //
8
+ //
9
+ // Your endpoint logic here. You can access route parameters in requestParams
10
+ //
18
11
 
19
- Response.success({
20
- message: 'Protected endpoint is working',
21
- params: requestParams,
22
- });
12
+ Response.success({
13
+ message: "Protected endpoint is working",
14
+ params: requestParams,
15
+ });
23
16
  };
@@ -1,24 +1,16 @@
1
- 'use strict';
2
-
3
- /**
4
- * Mirror of src/backend/api/public/example-public.php
5
- *
6
- * Public endpoint: no API key required. Receives the request context and calls
7
- * the request-bound Response helper, exactly like the PHP file used the global
8
- * Response class and $method / $requestParams.
9
- */
1
+ "use strict";
10
2
 
11
3
  module.exports = ({ method, requestParams, Response }) => {
12
- if (method !== 'GET') {
13
- Response.error('Method not allowed', 405);
14
- }
4
+ if (method !== "GET") {
5
+ Response.error("Method not allowed", 405);
6
+ }
15
7
 
16
- //
17
- // Your endpoint logic here. You can access route parameters in requestParams
18
- //
8
+ //
9
+ // Your endpoint logic here. You can access route parameters in requestParams
10
+ //
19
11
 
20
- Response.success({
21
- message: 'Public endpoint is working',
22
- params: requestParams,
23
- });
12
+ Response.success({
13
+ message: "Public endpoint is working",
14
+ params: requestParams,
15
+ });
24
16
  };
@@ -1,19 +1,5 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * Mirror of src/backend/database/Database.php
5
- *
6
- * PDO singleton → a single shared mysql2 connection pool. Config is read from
7
- * ../config.js (same relative location as the PHP version's ../config.php).
8
- * Options map the PDO attributes as closely as mysql2 allows:
9
- * - ERRMODE_EXCEPTION → mysql2 rejects/throws on error by default
10
- * - FETCH_ASSOC → rowsAsArray:false (objects keyed by column) is default
11
- * - EMULATE_PREPARES false → native prepared statements via pool.execute()
12
- *
13
- * Requires the "mysql2" package (npm install mysql2). getInstance() returns the
14
- * promise-based pool; use pool.execute(sql, params) for prepared statements.
15
- */
16
-
17
3
  const path = require('path');
18
4
 
19
5
  let instance = null;
@@ -21,11 +7,8 @@ let instance = null;
21
7
  class Database {
22
8
  static getInstance() {
23
9
  if (instance === null) {
24
- // Lazy require so the backend boots even without a DB configured.
25
- // eslint-disable-next-line global-require
26
10
  const mysql = require('mysql2/promise');
27
11
 
28
- // eslint-disable-next-line global-require
29
12
  const config = require(path.join(__dirname, '..', 'config.js'));
30
13
 
31
14
  instance = mysql.createPool({
@@ -1,37 +1,36 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
3
  /**
4
4
  * Mirror of src/backend/example.config.php
5
5
  *
6
- * Versioned, secret-free template. On setup, copy this file to config.js and
7
- * fill in real values. config.js is git-ignored and stays local.
6
+ * Copy this file to config.js and fill in real values. config.js is git-ignored and stays local.
8
7
  */
9
8
 
10
9
  module.exports = {
11
- // Default key for protected endpoints that don't have a specific key in CUSTOM_ENDPOINT_KEYS
12
- GENERAL_API_KEY: 'DEFAULT_KEY',
10
+ // Default key for protected endpoints that don't have a specific key in CUSTOM_ENDPOINT_KEYS
11
+ GENERAL_API_KEY: "DEFAULT_KEY",
13
12
 
14
- // If you want to restrict access to protected endpoints to specific clients, define custom keys per endpoint.
15
- // For subfolder endpoints, use the relative path ('subfolder/endpoint')
16
- CUSTOM_ENDPOINT_KEYS: {
17
- 'subfolder/example-protected': 'custom-key',
18
- },
13
+ // If you want to restrict access to protected endpoints to specific clients, define custom keys per endpoint.
14
+ // For subfolder endpoints, use the relative path ('subfolder/endpoint')
15
+ CUSTOM_ENDPOINT_KEYS: {
16
+ "subfolder/example-protected": "custom-key",
17
+ },
19
18
 
20
- GENERAL_ALLOWED_ORIGINS: [
21
- '*',
22
- // 'https://example.com',
23
- ],
19
+ GENERAL_ALLOWED_ORIGINS: [
20
+ "*",
21
+ // 'https://example.com',
22
+ ],
24
23
 
25
- CUSTOM_ENDPOINT_ORIGINS: {
26
- 'subfolder/example-protected': ['https://app.example.com'],
27
- },
24
+ CUSTOM_ENDPOINT_ORIGINS: {
25
+ "subfolder/example-protected": ["https://app.example.com"],
26
+ },
28
27
 
29
- // Database configuration
30
- DB_HOST: '127.0.0.1',
31
- DB_NAME: 'example_db',
32
- DB_USER: 'root',
33
- DB_PASS: '',
28
+ // Database configuration
29
+ DB_HOST: "127.0.0.1",
30
+ DB_NAME: "example_db",
31
+ DB_USER: "root",
32
+ DB_PASS: "",
34
33
 
35
- // Environment: 'production' hides error details; anything else = debug.
36
- APP_ENV: 'production',
34
+ // Environment: 'production' hides error details; anything else = debug.
35
+ APP_ENV: "production",
37
36
  };
@@ -2,10 +2,9 @@
2
2
  declare(strict_types=1);
3
3
 
4
4
  /**
5
- * Mirror of src/backend/example.config.js
5
+ * Mirror of src/backend/example.config.php
6
6
  *
7
- * Versioned, secret-free template. On setup, copy this file to config.php and
8
- * fill in real values. config.php is git-ignored and stays local.
7
+ * Copy this file to config.php and fill in real values. config.php is git-ignored and stays local.
9
8
  */
10
9
 
11
10
  return [
@@ -21,7 +21,9 @@ nav {
21
21
  max-height: root.$header-height;
22
22
  width: auto;
23
23
  text-decoration: none !important;
24
-
24
+ display: flex;
25
+ align-items: center;
26
+
25
27
  img {
26
28
  height: 50px;
27
29
  width: auto;
@@ -8,6 +8,11 @@
8
8
  // @import "../modules/moduleName";
9
9
 
10
10
  // Add any custom rule specific to this page below:
11
- // body {
12
- // background-color: root.$custom-color;
13
- // }
11
+ .not-found {
12
+ display: flex;
13
+ flex-direction: column;
14
+ justify-content: center;
15
+ align-items: center;
16
+ min-height: 60vh;
17
+ text-align: center;
18
+ }