nibula 1.2.4 → 1.2.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.
Files changed (81) hide show
  1. package/.eleventy.js +81 -81
  2. package/.eleventyignore +3 -3
  3. package/.github/workflows/publish.yml +37 -0
  4. package/CHANGELOG.md +179 -148
  5. package/LICENSE +169 -169
  6. package/NOTICE +4 -4
  7. package/README.md +120 -123
  8. package/bin/create.js +507 -507
  9. package/bin/nibula.js +317 -305
  10. package/docs/Assistant CLI.md +65 -65
  11. package/docs/Backend.md +295 -295
  12. package/docs/Components.md +84 -84
  13. package/docs/Creating pages.md +64 -64
  14. package/docs/Deploy.md +189 -189
  15. package/docs/Head and SEO.md +138 -138
  16. package/docs/Javascript.md +50 -50
  17. package/docs/Styling with SCSS.md +141 -141
  18. package/nginx.conf +75 -75
  19. package/package.json +1 -1
  20. package/src/backend/.htaccess +6 -6
  21. package/src/backend/_core/composer.json +5 -5
  22. package/src/backend/_core/composer.lock +492 -492
  23. package/src/backend/_core/index.js +267 -267
  24. package/src/backend/_core/index.php +147 -147
  25. package/src/backend/_core/init.js +52 -52
  26. package/src/backend/_core/init.php +33 -33
  27. package/src/backend/_core/modules/RateLimiter.js +58 -58
  28. package/src/backend/_core/modules/RateLimiter.php +30 -30
  29. package/src/backend/_core/modules/Response.js +59 -59
  30. package/src/backend/_core/modules/Response.php +48 -48
  31. package/src/backend/api/protected/example-protected.js +23 -23
  32. package/src/backend/api/protected/example-protected.php +16 -16
  33. package/src/backend/api/public/example-public.js +24 -24
  34. package/src/backend/api/public/example-public.php +16 -16
  35. package/src/backend/backend-node.service.example +30 -30
  36. package/src/backend/database/Database.js +46 -46
  37. package/src/backend/database/Database.php +23 -23
  38. package/src/backend/example.config.js +37 -37
  39. package/src/backend/example.config.php +27 -27
  40. package/src/backend/package.json +18 -18
  41. package/src/backend/web.config +16 -16
  42. package/src/frontend/.htaccess +51 -51
  43. package/src/frontend/404.njk +17 -17
  44. package/src/frontend/assets/brand/favicon.svg +54 -54
  45. package/src/frontend/assets/brand/logo.svg +54 -54
  46. package/src/frontend/components/global/footer.njk +25 -25
  47. package/src/frontend/components/global/header.njk +6 -6
  48. package/src/frontend/components/welcome.njk +114 -114
  49. package/src/frontend/data/site.json +48 -48
  50. package/src/frontend/index.njk +8 -8
  51. package/src/frontend/js/modules/exampleModule.js +2 -2
  52. package/src/frontend/js/pages/404.js +6 -6
  53. package/src/frontend/js/pages/homepage.js +6 -6
  54. package/src/frontend/layouts/base.njk +132 -132
  55. package/src/frontend/layouts/page-components.njk +13 -13
  56. package/src/frontend/llms.njk +17 -17
  57. package/src/frontend/robots.njk +7 -7
  58. package/src/frontend/scss/modules/_animations.scss +24 -24
  59. package/src/frontend/scss/modules/_footer.scss +27 -27
  60. package/src/frontend/scss/modules/_global.scss +47 -47
  61. package/src/frontend/scss/modules/_header.scss +27 -27
  62. package/src/frontend/scss/modules/_mobile.scss +29 -29
  63. package/src/frontend/scss/modules/_root.scss +34 -34
  64. package/src/frontend/scss/modules/_typography.scss +14 -14
  65. package/src/frontend/scss/modules/frameworks/_bootstrap.scss +109 -109
  66. package/src/frontend/scss/modules/frameworks/_bulma.scss +108 -108
  67. package/src/frontend/scss/modules/frameworks/_foundation.scss +138 -139
  68. package/src/frontend/scss/modules/frameworks/_uikit.scss +109 -109
  69. package/src/frontend/scss/pages/404.scss +27 -27
  70. package/src/frontend/scss/pages/homepage.scss +22 -22
  71. package/src/frontend/sitemap.njk +17 -17
  72. package/src/frontend/ts/modules/exampleModule.ts +2 -2
  73. package/src/frontend/ts/pages/404.ts +6 -6
  74. package/src/frontend/ts/pages/homepage.ts +6 -6
  75. package/src/frontend/web.config +55 -55
  76. package/tools/config/messages.json +3 -1
  77. package/tools/res/templates/template.js +6 -6
  78. package/tools/res/templates/template.njk +8 -8
  79. package/tools/res/templates/template.scss +22 -22
  80. package/tools/res/templates/template.ts +6 -6
  81. package/tsconfig.json +24 -24
@@ -1,148 +1,148 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- define('CORE_ACCESS', true);
6
- define('CORE_PATH', __DIR__);
7
-
8
- /**
9
- * Load dependencies and initial configuration.
10
- */
11
- require_once __DIR__ . '/init.php';
12
- require_once __DIR__ . '/modules/Response.php';
13
-
14
- // =====================================================
15
- // 1. REQUEST PARSING
16
- // =====================================================
17
-
18
- $method = $_SERVER['REQUEST_METHOD'];
19
- $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
20
-
21
- $uri = rtrim(preg_replace('#^/api#', '', $uri), '/') ?: '/';
22
- $parts = array_values(array_filter(explode('/', $uri)));
23
-
24
- foreach ($parts as $part) {
25
- if ($part === '..' || $part === '.') {
26
- Response::error('Bad Request', 400);
27
- }
28
- }
29
-
30
- // =====================================================
31
- // 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
32
- // =====================================================
33
-
34
- $basePublic = __DIR__ . '/../api/public/';
35
- $baseProtected = __DIR__ . '/../api/protected/';
36
-
37
- $endpointFile = null;
38
- $isProtected = false;
39
- $requestParams = [];
40
-
41
- // Temporary variables for the lookup loop
42
- $checkParts = $parts;
43
- $params = [];
44
-
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
- while (count($checkParts) > 0) {
51
- $relativePath = implode('/', $checkParts) . '.php';
52
-
53
- // Check first whether this is a public route
54
- if (file_exists($basePublic . $relativePath)) {
55
- $endpointFile = $basePublic . $relativePath;
56
- $isProtected = false;
57
- break;
58
- }
59
-
60
- // Then check whether this is a protected route
61
- if (file_exists($baseProtected . $relativePath)) {
62
- $endpointFile = $baseProtected . $relativePath;
63
- $isProtected = true;
64
- break;
65
- }
66
-
67
- // No file matched: treat the last segment as a route parameter and try again
68
- array_unshift($params, array_pop($checkParts));
69
- }
70
-
71
- /**
72
- * IF THE ENDPOINT DOES NOT EXIST, RETURN AN HTML 404 PAGE.
73
- */
74
- if (!$endpointFile) {
75
- http_response_code(404);
76
-
77
- // Look for the 404.html file generated by Eleventy in the server document root
78
- $errorPage = $_SERVER['DOCUMENT_ROOT'] . '/404.html';
79
-
80
- if (file_exists($errorPage)) {
81
- header('Content-Type: text/html; charset=UTF-8');
82
- echo file_get_contents($errorPage);
83
- } else {
84
- echo "<h1>404 Not Found</h1>";
85
- echo "The requested URL was not found on this server.";
86
- }
87
- exit;
88
- }
89
-
90
- $base = $isProtected ? $baseProtected : $basePublic;
91
- $base = str_replace('\\', '/', $base);
92
- $endpointPath = str_replace('\\', '/', $endpointFile);
93
- $endpointPath = preg_replace('#\.php$#', '', str_replace($base, '', $endpointPath));
94
-
95
- // =====================================================
96
- // 3. HEADERS AND CORS (Only if the endpoint exists)
97
- // =====================================================
98
-
99
- header('Content-Type: application/json; charset=UTF-8');
100
- header('X-Content-Type-Options: nosniff');
101
- header('X-Frame-Options: DENY');
102
- header('Referrer-Policy: strict-origin-when-cross-origin');
103
- header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
104
- header('Access-Control-Allow-Headers: Content-Type, X-Api-Key');
105
-
106
- $originsForEndpoint = $config['CUSTOM_ENDPOINT_ORIGINS'][$endpointPath]
107
- ?? $config['GENERAL_ALLOWED_ORIGINS']
108
- ?? [];
109
-
110
- $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
111
-
112
- if ($origin !== '' && in_array($origin, $originsForEndpoint, true)) {
113
- header('Access-Control-Allow-Origin: ' . $origin);
114
- header('Vary: Origin');
115
- } elseif (in_array('*', $originsForEndpoint, true)) {
116
- header('Access-Control-Allow-Origin: *');
117
- }
118
-
119
- if ($method === 'OPTIONS') {
120
- http_response_code(204);
121
- exit;
122
- }
123
-
124
- require_once __DIR__ . '/modules/RateLimiter.php';
125
- RateLimiter::check($_SERVER['REMOTE_ADDR'] ?? '', 60, 60);
126
-
127
- // =====================================================
128
- // 4. AUTHENTICATION GUARD
129
- // =====================================================
130
-
131
- if ($isProtected) {
132
- $validKey = $config['CUSTOM_ENDPOINT_KEYS'][$endpointPath] ?? $config['GENERAL_API_KEY'] ?? '';
133
- $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
134
-
135
- if ($validKey === '' || !hash_equals($validKey, $apiKey)) {
136
- Response::error('Unauthorized. X_API_KEY is incorrect or missing', 401);
137
- }
138
- }
139
-
140
- // =====================================================
141
- // 5. DISPATCH
142
- // =====================================================
143
-
144
- // Parameters are the remaining URL segments not consumed during endpoint resolution
145
- $requestParams = $params;
146
-
147
- // Load and execute the matched endpoint file
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ define('CORE_ACCESS', true);
6
+ define('CORE_PATH', __DIR__);
7
+
8
+ /**
9
+ * Load dependencies and initial configuration.
10
+ */
11
+ require_once __DIR__ . '/init.php';
12
+ require_once __DIR__ . '/modules/Response.php';
13
+
14
+ // =====================================================
15
+ // 1. REQUEST PARSING
16
+ // =====================================================
17
+
18
+ $method = $_SERVER['REQUEST_METHOD'];
19
+ $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
20
+
21
+ $uri = rtrim(preg_replace('#^/api#', '', $uri), '/') ?: '/';
22
+ $parts = array_values(array_filter(explode('/', $uri)));
23
+
24
+ foreach ($parts as $part) {
25
+ if ($part === '..' || $part === '.') {
26
+ Response::error('Bad Request', 400);
27
+ }
28
+ }
29
+
30
+ // =====================================================
31
+ // 2. ENDPOINT RESOLUTION (ROUTING WITH SUBDIRECTORIES)
32
+ // =====================================================
33
+
34
+ $basePublic = __DIR__ . '/../api/public/';
35
+ $baseProtected = __DIR__ . '/../api/protected/';
36
+
37
+ $endpointFile = null;
38
+ $isProtected = false;
39
+ $requestParams = [];
40
+
41
+ // Temporary variables for the lookup loop
42
+ $checkParts = $parts;
43
+ $params = [];
44
+
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
+ while (count($checkParts) > 0) {
51
+ $relativePath = implode('/', $checkParts) . '.php';
52
+
53
+ // Check first whether this is a public route
54
+ if (file_exists($basePublic . $relativePath)) {
55
+ $endpointFile = $basePublic . $relativePath;
56
+ $isProtected = false;
57
+ break;
58
+ }
59
+
60
+ // Then check whether this is a protected route
61
+ if (file_exists($baseProtected . $relativePath)) {
62
+ $endpointFile = $baseProtected . $relativePath;
63
+ $isProtected = true;
64
+ break;
65
+ }
66
+
67
+ // No file matched: treat the last segment as a route parameter and try again
68
+ array_unshift($params, array_pop($checkParts));
69
+ }
70
+
71
+ /**
72
+ * IF THE ENDPOINT DOES NOT EXIST, RETURN AN HTML 404 PAGE.
73
+ */
74
+ if (!$endpointFile) {
75
+ http_response_code(404);
76
+
77
+ // Look for the 404.html file generated by Eleventy in the server document root
78
+ $errorPage = $_SERVER['DOCUMENT_ROOT'] . '/404.html';
79
+
80
+ if (file_exists($errorPage)) {
81
+ header('Content-Type: text/html; charset=UTF-8');
82
+ echo file_get_contents($errorPage);
83
+ } else {
84
+ echo "<h1>404 Not Found</h1>";
85
+ echo "The requested URL was not found on this server.";
86
+ }
87
+ exit;
88
+ }
89
+
90
+ $base = $isProtected ? $baseProtected : $basePublic;
91
+ $base = str_replace('\\', '/', $base);
92
+ $endpointPath = str_replace('\\', '/', $endpointFile);
93
+ $endpointPath = preg_replace('#\.php$#', '', str_replace($base, '', $endpointPath));
94
+
95
+ // =====================================================
96
+ // 3. HEADERS AND CORS (Only if the endpoint exists)
97
+ // =====================================================
98
+
99
+ header('Content-Type: application/json; charset=UTF-8');
100
+ header('X-Content-Type-Options: nosniff');
101
+ header('X-Frame-Options: DENY');
102
+ header('Referrer-Policy: strict-origin-when-cross-origin');
103
+ header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
104
+ header('Access-Control-Allow-Headers: Content-Type, X-Api-Key');
105
+
106
+ $originsForEndpoint = $config['CUSTOM_ENDPOINT_ORIGINS'][$endpointPath]
107
+ ?? $config['GENERAL_ALLOWED_ORIGINS']
108
+ ?? [];
109
+
110
+ $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
111
+
112
+ if ($origin !== '' && in_array($origin, $originsForEndpoint, true)) {
113
+ header('Access-Control-Allow-Origin: ' . $origin);
114
+ header('Vary: Origin');
115
+ } elseif (in_array('*', $originsForEndpoint, true)) {
116
+ header('Access-Control-Allow-Origin: *');
117
+ }
118
+
119
+ if ($method === 'OPTIONS') {
120
+ http_response_code(204);
121
+ exit;
122
+ }
123
+
124
+ require_once __DIR__ . '/modules/RateLimiter.php';
125
+ RateLimiter::check($_SERVER['REMOTE_ADDR'] ?? '', 60, 60);
126
+
127
+ // =====================================================
128
+ // 4. AUTHENTICATION GUARD
129
+ // =====================================================
130
+
131
+ if ($isProtected) {
132
+ $validKey = $config['CUSTOM_ENDPOINT_KEYS'][$endpointPath] ?? $config['GENERAL_API_KEY'] ?? '';
133
+ $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
134
+
135
+ if ($validKey === '' || !hash_equals($validKey, $apiKey)) {
136
+ Response::error('Unauthorized. X_API_KEY is incorrect or missing', 401);
137
+ }
138
+ }
139
+
140
+ // =====================================================
141
+ // 5. DISPATCH
142
+ // =====================================================
143
+
144
+ // Parameters are the remaining URL segments not consumed during endpoint resolution
145
+ $requestParams = $params;
146
+
147
+ // Load and execute the matched endpoint file
148
148
  require $endpointFile;
@@ -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 };
@@ -1,34 +1,34 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- require_once __DIR__ . '/vendor/autoload.php';
6
- require_once __DIR__ . '/modules/Response.php';
7
-
8
- // --- GESTORE GLOBALE ERRORI E ECCEZIONI ---
9
- set_exception_handler(function ($exception) use (&$config) {
10
- $isDebug = ($config['APP_ENV'] ?? 'production') !== 'production';
11
- Response::error(
12
- $isDebug ? $exception->getMessage() : 'Internal server error',
13
- 500,
14
- $isDebug ? ['file' => $exception->getFile(), 'line' => $exception->getLine()] : null
15
- );
16
- });
17
-
18
- set_error_handler(function ($severity, $message, $file, $line) {
19
- if (!(error_reporting() & $severity)) return;
20
- throw new ErrorException($message, 0, $severity, $file, $line);
21
- });
22
-
23
- // --- CARICAMENTO CONFIGURAZIONE ---
24
- // dirname(__DIR__) punta alla cartella /api/ dove ora si trova config.php
25
- $config = require dirname(__DIR__) . '/config.php';
26
-
27
- // --- CONFIGURAZIONE AMBIENTE ---
28
- if (($config['APP_ENV'] ?? 'production') === 'production') {
29
- ini_set('display_errors', '0');
30
- error_reporting(0);
31
- } else {
32
- ini_set('display_errors', '1');
33
- error_reporting(E_ALL);
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ require_once __DIR__ . '/vendor/autoload.php';
6
+ require_once __DIR__ . '/modules/Response.php';
7
+
8
+ // --- GESTORE GLOBALE ERRORI E ECCEZIONI ---
9
+ set_exception_handler(function ($exception) use (&$config) {
10
+ $isDebug = ($config['APP_ENV'] ?? 'production') !== 'production';
11
+ Response::error(
12
+ $isDebug ? $exception->getMessage() : 'Internal server error',
13
+ 500,
14
+ $isDebug ? ['file' => $exception->getFile(), 'line' => $exception->getLine()] : null
15
+ );
16
+ });
17
+
18
+ set_error_handler(function ($severity, $message, $file, $line) {
19
+ if (!(error_reporting() & $severity)) return;
20
+ throw new ErrorException($message, 0, $severity, $file, $line);
21
+ });
22
+
23
+ // --- CARICAMENTO CONFIGURAZIONE ---
24
+ // dirname(__DIR__) punta alla cartella /api/ dove ora si trova config.php
25
+ $config = require dirname(__DIR__) . '/config.php';
26
+
27
+ // --- CONFIGURAZIONE AMBIENTE ---
28
+ if (($config['APP_ENV'] ?? 'production') === 'production') {
29
+ ini_set('display_errors', '0');
30
+ error_reporting(0);
31
+ } else {
32
+ ini_set('display_errors', '1');
33
+ error_reporting(E_ALL);
34
34
  }
@@ -1,58 +1,58 @@
1
- 'use strict';
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');
15
-
16
- 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
- }
55
- }
56
- }
57
-
58
- module.exports = RateLimiter;
1
+ 'use strict';
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');
15
+
16
+ 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
+ }
55
+ }
56
+ }
57
+
58
+ module.exports = RateLimiter;
@@ -1,31 +1,31 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- class RateLimiter {
6
- public static function check(string $ip, int $maxRequests = 60, int $windowSeconds = 60): void {
7
- $cacheDir = __DIR__ . '/../../cache';
8
-
9
- if (!is_dir($cacheDir)) {
10
- mkdir($cacheDir, 0755, true);
11
- }
12
-
13
- $cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
14
- $now = time();
15
- $data = [];
16
-
17
- if (file_exists($cacheFile)) {
18
- $data = json_decode(file_get_contents($cacheFile), true) ?: [];
19
- }
20
-
21
- $data = array_filter($data, fn($ts) => $ts > ($now - $windowSeconds));
22
- $data[] = $now;
23
-
24
- file_put_contents($cacheFile, json_encode(array_values($data)), LOCK_EX);
25
-
26
- if (count($data) > $maxRequests) {
27
- header('Retry-After: ' . $windowSeconds);
28
- Response::error('Too Many Requests', 429);
29
- }
30
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ class RateLimiter {
6
+ public static function check(string $ip, int $maxRequests = 60, int $windowSeconds = 60): void {
7
+ $cacheDir = __DIR__ . '/../../cache';
8
+
9
+ if (!is_dir($cacheDir)) {
10
+ mkdir($cacheDir, 0755, true);
11
+ }
12
+
13
+ $cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
14
+ $now = time();
15
+ $data = [];
16
+
17
+ if (file_exists($cacheFile)) {
18
+ $data = json_decode(file_get_contents($cacheFile), true) ?: [];
19
+ }
20
+
21
+ $data = array_filter($data, fn($ts) => $ts > ($now - $windowSeconds));
22
+ $data[] = $now;
23
+
24
+ file_put_contents($cacheFile, json_encode(array_values($data)), LOCK_EX);
25
+
26
+ if (count($data) > $maxRequests) {
27
+ header('Retry-After: ' . $windowSeconds);
28
+ Response::error('Too Many Requests', 429);
29
+ }
30
+ }
31
31
  }