nibula 2.5.0 → 3.0.1

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 (50) hide show
  1. package/.eleventy.js +0 -7
  2. package/CHANGELOG.md +53 -3
  3. package/bin/create.js +43 -45
  4. package/bin/nibula.js +2 -8
  5. package/nginx.conf +1 -1
  6. package/package.json +3 -7
  7. package/src/backend/.htaccess +2 -2
  8. package/src/backend/core/index.js +329 -0
  9. package/src/backend/core/index.php +207 -0
  10. package/src/backend/core/init.js +37 -0
  11. package/src/backend/core/init.php +54 -0
  12. package/src/backend/core/modules/RateLimiter.js +55 -0
  13. package/src/backend/core/modules/RateLimiter.php +39 -0
  14. package/src/backend/{_core → core}/modules/Response.js +15 -10
  15. package/src/backend/{_core → core}/modules/Response.php +14 -17
  16. package/src/backend/core/modules/Validate.js +56 -0
  17. package/src/backend/core/modules/Validate.php +52 -0
  18. package/src/backend/endpoints/protected/example-protected.js +39 -0
  19. package/src/backend/endpoints/protected/example-protected.php +47 -0
  20. package/src/backend/endpoints/public/example-public.js +39 -0
  21. package/src/backend/endpoints/public/example-public.php +47 -0
  22. package/src/backend/example.config.js +7 -6
  23. package/src/backend/example.config.php +8 -7
  24. package/src/backend/package.json +3 -3
  25. package/src/backend/web.config +2 -2
  26. package/src/frontend/hosting/.htaccess +1 -1
  27. package/src/frontend/hosting/web.config +1 -1
  28. package/src/frontend/layouts/base.njk +3 -7
  29. package/src/frontend/scss/{_global.scss → global.scss} +0 -2
  30. package/src/frontend/scss/modules/frameworks/_bootstrap.scss +0 -2
  31. package/src/frontend/scss/modules/frameworks/_bulma.scss +0 -2
  32. package/src/frontend/scss/pages/404.scss +0 -3
  33. package/src/frontend/scss/pages/homepage.scss +0 -3
  34. package/tools/res/templates/template.json +13 -0
  35. package/tools/res/templates/template.scss +0 -3
  36. package/src/backend/_core/index.js +0 -230
  37. package/src/backend/_core/index.php +0 -118
  38. package/src/backend/_core/init.js +0 -32
  39. package/src/backend/_core/init.php +0 -30
  40. package/src/backend/_core/modules/RateLimiter.js +0 -47
  41. package/src/backend/_core/modules/RateLimiter.php +0 -31
  42. package/src/backend/api/protected/example-protected.js +0 -16
  43. package/src/backend/api/protected/example-protected.php +0 -17
  44. package/src/backend/api/public/example-public.js +0 -16
  45. package/src/backend/api/public/example-public.php +0 -17
  46. package/src/frontend/scss/modules/frameworks/_foundation.scss +0 -138
  47. package/src/frontend/scss/modules/frameworks/_uikit.scss +0 -110
  48. package/tools/buildJs.js +0 -39
  49. /package/src/backend/{_core → core}/composer.json +0 -0
  50. /package/src/backend/{_core → core}/composer.lock +0 -0
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+
3
+ const DEBUG_HEADER = "Debug-Mode";
4
+ const DEBUG_HEADER_VALUE = "true";
5
+
6
+ const http = require("http");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const crypto = require("crypto");
10
+
11
+ const { createResponse, HALT } = require("./modules/Response");
12
+ const { createValidator } = require("./modules/Validate");
13
+ const { createRateLimiter } = require("./modules/RateLimiter");
14
+ const { loadConfig, isDebug, handleException } = require("./init");
15
+
16
+ const CORE_PATH = __dirname;
17
+
18
+ const URL_PREFIX = "/api";
19
+ const ENDPOINT_EXTENSION = ".js";
20
+ const ERROR_PAGE = "404.html";
21
+ const ENDPOINTS_FOLDER = "endpoints";
22
+ const PUBLIC_FOLDER = "public";
23
+ const PROTECTED_FOLDER = "protected";
24
+
25
+ const DEFAULT_HOST = "127.0.0.1";
26
+ const DEFAULT_PORT = "3000";
27
+ const RATE_LIMIT_REQUESTS = 60;
28
+ const RATE_LIMIT_WINDOW_SECONDS = 60;
29
+
30
+ const STATUS_NO_CONTENT = 204;
31
+ const STATUS_BAD_REQUEST = 400;
32
+ const STATUS_UNAUTHORIZED = 401;
33
+ const STATUS_NOT_FOUND = 404;
34
+ const STATUS_METHOD_NOT_ALLOWED = 405;
35
+ const STATUS_SERVER_ERROR = 500;
36
+
37
+ const ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
38
+ const ALLOWED_HEADERS = "Content-Type, X-Api-Key";
39
+ const JSON_CONTENT_TYPE = "application/json; charset=UTF-8";
40
+ const HTML_CONTENT_TYPE = "text/html; charset=UTF-8";
41
+
42
+ const config = loadConfig();
43
+ const debugMode = isDebug(config);
44
+
45
+ const DOCUMENT_ROOT =
46
+ process.env.DOCUMENT_ROOT || path.join(CORE_PATH, "..", "..");
47
+ const basePublic = path.join(CORE_PATH, "..", ENDPOINTS_FOLDER, PUBLIC_FOLDER);
48
+ const baseProtected = path.join(
49
+ CORE_PATH,
50
+ "..",
51
+ ENDPOINTS_FOLDER,
52
+ PROTECTED_FOLDER,
53
+ );
54
+
55
+ function hashEquals(known, given) {
56
+ const knownBuffer = Buffer.from(String(known));
57
+ const givenBuffer = Buffer.from(String(given));
58
+
59
+ if (knownBuffer.length !== givenBuffer.length) {
60
+ return false;
61
+ }
62
+
63
+ return crypto.timingSafeEqual(knownBuffer, givenBuffer);
64
+ }
65
+
66
+ function sendErrorPage(res) {
67
+ const errorPage = path.join(DOCUMENT_ROOT, ERROR_PAGE);
68
+
69
+ res.statusCode = STATUS_NOT_FOUND;
70
+ res.setHeader("Content-Type", HTML_CONTENT_TYPE);
71
+ res.end(
72
+ fs.existsSync(errorPage)
73
+ ? fs.readFileSync(errorPage)
74
+ : "<h1>404 Not Found</h1>The requested URL was not found on this server.",
75
+ );
76
+ }
77
+
78
+ function parseSegments(pathname) {
79
+ const uri = pathname
80
+ .replace(new RegExp(`^${URL_PREFIX}`), "")
81
+ .replace(/\/+$/, "");
82
+
83
+ return uri.split("/").filter((segment) => segment !== "");
84
+ }
85
+
86
+ function resolveEndpoint(segments) {
87
+ const remaining = segments.slice();
88
+ const params = [];
89
+
90
+ while (remaining.length > 0) {
91
+ const relativePath = remaining.join("/") + ENDPOINT_EXTENSION;
92
+
93
+ const publicCandidate = path.join(basePublic, relativePath);
94
+ if (fs.existsSync(publicCandidate)) {
95
+ return {
96
+ file: publicCandidate,
97
+ base: basePublic,
98
+ isProtected: false,
99
+ params,
100
+ };
101
+ }
102
+
103
+ const protectedCandidate = path.join(baseProtected, relativePath);
104
+ if (fs.existsSync(protectedCandidate)) {
105
+ return {
106
+ file: protectedCandidate,
107
+ base: baseProtected,
108
+ isProtected: true,
109
+ params,
110
+ };
111
+ }
112
+
113
+ params.unshift(remaining.pop());
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ function toEndpointKey(file, base) {
120
+ return file
121
+ .split(path.sep)
122
+ .join("/")
123
+ .replace(base.split(path.sep).join("/"), "")
124
+ .replace(new RegExp(`\\${ENDPOINT_EXTENSION}$`), "")
125
+ .replace(/^\//, "");
126
+ }
127
+
128
+ function applySecurityHeaders(res) {
129
+ res.setHeader("Content-Type", JSON_CONTENT_TYPE);
130
+ res.setHeader("X-Content-Type-Options", "nosniff");
131
+ res.setHeader("X-Frame-Options", "DENY");
132
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
133
+ res.setHeader("Access-Control-Allow-Methods", ALLOWED_METHODS);
134
+ res.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS);
135
+
136
+ if (debugMode) {
137
+ res.setHeader(DEBUG_HEADER, DEBUG_HEADER_VALUE);
138
+ }
139
+ }
140
+
141
+ function applyCors(res, endpointKey, origin) {
142
+ const allowedOrigins =
143
+ config.CUSTOM_ENDPOINT_ORIGINS?.[endpointKey] ??
144
+ config.GENERAL_ALLOWED_ORIGINS ??
145
+ [];
146
+
147
+ if (origin !== "" && allowedOrigins.includes(origin)) {
148
+ res.setHeader("Access-Control-Allow-Origin", origin);
149
+ res.setHeader("Vary", "Origin");
150
+
151
+ return;
152
+ }
153
+
154
+ if (allowedOrigins.includes("*")) {
155
+ res.setHeader("Access-Control-Allow-Origin", "*");
156
+ }
157
+ }
158
+
159
+ function authorize(endpointKey, givenKey, Response) {
160
+ const validKey =
161
+ config.CUSTOM_ENDPOINT_KEYS?.[endpointKey] ?? config.GENERAL_API_KEY ?? "";
162
+
163
+ if (validKey === "" || !hashEquals(validKey, givenKey)) {
164
+ Response.error(
165
+ "Unauthorized. X-Api-Key is incorrect or missing",
166
+ STATUS_UNAUTHORIZED,
167
+ );
168
+ }
169
+ }
170
+
171
+ function clientAddress(req) {
172
+ const forwarded = (req.headers["x-forwarded-for"] || "").split(",")[0].trim();
173
+
174
+ return forwarded || req.socket.remoteAddress || "";
175
+ }
176
+
177
+ function readBody(req) {
178
+ return new Promise((resolve) => {
179
+ const chunks = [];
180
+
181
+ req.on("data", (chunk) => chunks.push(chunk));
182
+ req.on("end", () => {
183
+ const raw = Buffer.concat(chunks).toString("utf8");
184
+ let json = {};
185
+
186
+ try {
187
+ json = raw ? JSON.parse(raw) : {};
188
+ } catch (error) {
189
+ json = {};
190
+ }
191
+
192
+ resolve({ raw, json });
193
+ });
194
+ req.on("error", () => resolve({ raw: "", json: {} }));
195
+ });
196
+ }
197
+
198
+ function loadHandlers(file) {
199
+ if (debugMode) {
200
+ delete require.cache[require.resolve(file)];
201
+ }
202
+
203
+ return require(file);
204
+ }
205
+
206
+ async function dispatch(file, context, Response) {
207
+ const handlers = loadHandlers(file);
208
+
209
+ if (typeof handlers === "function") {
210
+ await handlers(context);
211
+
212
+ return;
213
+ }
214
+
215
+ const methodHandler = handlers[context.method.toLowerCase()];
216
+
217
+ if (typeof methodHandler !== "function") {
218
+ Response.error("Method not allowed", STATUS_METHOD_NOT_ALLOWED);
219
+ }
220
+
221
+ await methodHandler(context);
222
+ }
223
+
224
+ async function handleRequest(req, res) {
225
+ const Response = createResponse(res);
226
+
227
+ try {
228
+ const parsedUrl = new URL(req.url, `http://${DEFAULT_HOST}`);
229
+ const segments = parseSegments(parsedUrl.pathname);
230
+
231
+ if (segments.some((segment) => segment === "." || segment === "..")) {
232
+ Response.error("Bad Request", STATUS_BAD_REQUEST);
233
+ }
234
+
235
+ const endpoint = resolveEndpoint(segments);
236
+
237
+ if (endpoint === null) {
238
+ sendErrorPage(res);
239
+
240
+ return;
241
+ }
242
+
243
+ const endpointKey = toEndpointKey(endpoint.file, endpoint.base);
244
+
245
+ applySecurityHeaders(res);
246
+ applyCors(res, endpointKey, req.headers.origin || "");
247
+
248
+ if (req.method === "OPTIONS") {
249
+ res.statusCode = STATUS_NO_CONTENT;
250
+ res.end();
251
+
252
+ return;
253
+ }
254
+
255
+ createRateLimiter(Response, res).check(
256
+ clientAddress(req),
257
+ RATE_LIMIT_REQUESTS,
258
+ RATE_LIMIT_WINDOW_SECONDS,
259
+ );
260
+
261
+ if (endpoint.isProtected) {
262
+ authorize(endpointKey, req.headers["x-api-key"] || "", Response);
263
+ }
264
+
265
+ const body = await readBody(req);
266
+
267
+ await dispatch(
268
+ endpoint.file,
269
+ {
270
+ method: req.method,
271
+ requestParams: endpoint.params,
272
+ query: Object.fromEntries(parsedUrl.searchParams),
273
+ body: body.json,
274
+ rawBody: body.raw,
275
+ headers: req.headers,
276
+ Response,
277
+ Validate: createValidator(Response),
278
+ config,
279
+ CORE_PATH,
280
+ req,
281
+ res,
282
+ },
283
+ Response,
284
+ );
285
+
286
+ if (!res.writableEnded) {
287
+ res.end();
288
+ }
289
+ } catch (error) {
290
+ if (error === HALT) {
291
+ return;
292
+ }
293
+
294
+ try {
295
+ handleException(error, config, Response);
296
+ } catch (inner) {
297
+ if (inner === HALT) {
298
+ return;
299
+ }
300
+
301
+ if (!res.writableEnded) {
302
+ res.statusCode = STATUS_SERVER_ERROR;
303
+ res.end(
304
+ JSON.stringify({
305
+ status: "error",
306
+ message: "Internal server error",
307
+ code: STATUS_SERVER_ERROR,
308
+ }),
309
+ );
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ const host = process.env.HOST || DEFAULT_HOST;
316
+ const port = parseInt(process.env.PORT || DEFAULT_PORT, 10);
317
+
318
+ const server = http.createServer(handleRequest);
319
+
320
+ server.listen(port, host, () => {
321
+ console.log(`Backend started listening on http://${host}:${port}`);
322
+ if (debugMode) {
323
+ console.log(
324
+ 'WARNING: error details are exposed in responses. Set APP_ENV to "production" in config.js before publishing.',
325
+ );
326
+ }
327
+ });
328
+
329
+ module.exports = server;
@@ -0,0 +1,207 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ define('CORE_PATH', __DIR__);
6
+
7
+ const DEBUG_HEADER = 'Debug-Mode';
8
+ const DEBUG_HEADER_VALUE = 'true';
9
+
10
+ const URL_PREFIX = '/api';
11
+ const ENDPOINT_EXTENSION = '.php';
12
+ const ERROR_PAGE = '404.html';
13
+ const ENDPOINTS_FOLDER = 'endpoints';
14
+ const PUBLIC_FOLDER = 'public';
15
+ const PROTECTED_FOLDER = 'protected';
16
+
17
+ const RATE_LIMIT_REQUESTS = 60;
18
+ const RATE_LIMIT_WINDOW_SECONDS = 60;
19
+
20
+ const STATUS_NO_CONTENT = 204;
21
+ const STATUS_BAD_REQUEST = 400;
22
+ const STATUS_UNAUTHORIZED = 401;
23
+ const STATUS_NOT_FOUND = 404;
24
+ const STATUS_METHOD_NOT_ALLOWED = 405;
25
+
26
+ const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
27
+ const ALLOWED_HEADERS = 'Content-Type, X-Api-Key';
28
+ const JSON_CONTENT_TYPE = 'application/json; charset=UTF-8';
29
+ const HTML_CONTENT_TYPE = 'text/html; charset=UTF-8';
30
+
31
+ require_once __DIR__ . '/init.php';
32
+ require_once __DIR__ . '/modules/Response.php';
33
+ require_once __DIR__ . '/modules/RateLimiter.php';
34
+ require_once __DIR__ . '/modules/Validate.php';
35
+
36
+ $basePublic = __DIR__ . '/../' . ENDPOINTS_FOLDER . '/' . PUBLIC_FOLDER . '/';
37
+ $baseProtected = __DIR__ . '/../' . ENDPOINTS_FOLDER . '/' . PROTECTED_FOLDER . '/';
38
+
39
+ function parseSegments(string $pathname): array
40
+ {
41
+ $uri = rtrim(preg_replace('#^' . URL_PREFIX . '#', '', $pathname), '/');
42
+
43
+ return array_values(array_filter(explode('/', $uri)));
44
+ }
45
+
46
+ function resolveEndpoint(array $segments, string $basePublic, string $baseProtected): ?array
47
+ {
48
+ $remaining = $segments;
49
+ $params = [];
50
+
51
+ while (count($remaining) > 0) {
52
+ $relativePath = implode('/', $remaining) . ENDPOINT_EXTENSION;
53
+
54
+ if (file_exists($basePublic . $relativePath)) {
55
+ return [
56
+ 'file' => $basePublic . $relativePath,
57
+ 'base' => $basePublic,
58
+ 'isProtected' => false,
59
+ 'params' => $params,
60
+ ];
61
+ }
62
+
63
+ if (file_exists($baseProtected . $relativePath)) {
64
+ return [
65
+ 'file' => $baseProtected . $relativePath,
66
+ 'base' => $baseProtected,
67
+ 'isProtected' => true,
68
+ 'params' => $params,
69
+ ];
70
+ }
71
+
72
+ array_unshift($params, array_pop($remaining));
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ function toEndpointKey(string $file, string $base): string
79
+ {
80
+ $normalizedBase = str_replace('\\', '/', $base);
81
+ $normalizedFile = str_replace('\\', '/', $file);
82
+
83
+ return preg_replace('#\\' . ENDPOINT_EXTENSION . '$#', '', str_replace($normalizedBase, '', $normalizedFile));
84
+ }
85
+
86
+ function sendErrorPage(): never
87
+ {
88
+ http_response_code(STATUS_NOT_FOUND);
89
+ header_remove('WWW-Authenticate');
90
+ header_remove('X-Powered-By');
91
+ header('Content-Type: ' . HTML_CONTENT_TYPE);
92
+
93
+ $errorPage = ($_SERVER['DOCUMENT_ROOT'] ?? '') . '/' . ERROR_PAGE;
94
+
95
+ if (!file_exists($errorPage)) {
96
+ $errorPage = __DIR__ . '/../../' . ERROR_PAGE;
97
+ }
98
+
99
+ echo file_exists($errorPage)
100
+ ? file_get_contents($errorPage)
101
+ : '<h1>404 Not Found</h1>The requested URL was not found on this server.';
102
+
103
+ exit;
104
+ }
105
+
106
+ function applySecurityHeaders(bool $isDebug): void
107
+ {
108
+ header('Content-Type: ' . JSON_CONTENT_TYPE);
109
+ header_remove('WWW-Authenticate');
110
+ header_remove('X-Powered-By');
111
+ header('X-Content-Type-Options: nosniff');
112
+ header('X-Frame-Options: DENY');
113
+ header('Referrer-Policy: strict-origin-when-cross-origin');
114
+ header('Access-Control-Allow-Methods: ' . ALLOWED_METHODS);
115
+ header('Access-Control-Allow-Headers: ' . ALLOWED_HEADERS);
116
+
117
+ if ($isDebug) {
118
+ header(DEBUG_HEADER . ': ' . DEBUG_HEADER_VALUE);
119
+ }
120
+ }
121
+
122
+ function applyCors(array $config, string $endpointKey, string $origin): void
123
+ {
124
+ $allowedOrigins = $config['CUSTOM_ENDPOINT_ORIGINS'][$endpointKey]
125
+ ?? $config['GENERAL_ALLOWED_ORIGINS']
126
+ ?? [];
127
+
128
+ if ($origin !== '' && in_array($origin, $allowedOrigins, true)) {
129
+ header('Access-Control-Allow-Origin: ' . $origin);
130
+ header('Vary: Origin');
131
+
132
+ return;
133
+ }
134
+
135
+ if (in_array('*', $allowedOrigins, true)) {
136
+ header('Access-Control-Allow-Origin: *');
137
+ }
138
+ }
139
+
140
+ function authorize(array $config, string $endpointKey, string $givenKey): void
141
+ {
142
+ $validKey = $config['CUSTOM_ENDPOINT_KEYS'][$endpointKey] ?? $config['GENERAL_API_KEY'] ?? '';
143
+
144
+ if ($validKey === '' || !hash_equals($validKey, $givenKey)) {
145
+ Response::error('Unauthorized. X-Api-Key is incorrect or missing', STATUS_UNAUTHORIZED);
146
+ }
147
+ }
148
+
149
+ function dispatch(string $endpointFile, array $request): void
150
+ {
151
+ $handlers = require $endpointFile;
152
+
153
+ if (!is_array($handlers)) {
154
+ return;
155
+ }
156
+
157
+ $methodHandler = $handlers[strtolower($request['method'])] ?? null;
158
+
159
+ if (!is_callable($methodHandler)) {
160
+ Response::error('Method not allowed', STATUS_METHOD_NOT_ALLOWED);
161
+ }
162
+
163
+ $methodHandler($request);
164
+ }
165
+
166
+ $method = $_SERVER['REQUEST_METHOD'];
167
+ $segments = parseSegments(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
168
+
169
+ foreach ($segments as $segment) {
170
+ if ($segment === '.' || $segment === '..') {
171
+ Response::error('Bad Request', STATUS_BAD_REQUEST);
172
+ }
173
+ }
174
+
175
+ $endpoint = resolveEndpoint($segments, $basePublic, $baseProtected);
176
+
177
+ if ($endpoint === null) {
178
+ sendErrorPage();
179
+ }
180
+
181
+ $endpointKey = toEndpointKey($endpoint['file'], $endpoint['base']);
182
+
183
+ applySecurityHeaders($isDebug);
184
+ applyCors($config, $endpointKey, $_SERVER['HTTP_ORIGIN'] ?? '');
185
+
186
+ if ($method === 'OPTIONS') {
187
+ http_response_code(STATUS_NO_CONTENT);
188
+ exit;
189
+ }
190
+
191
+ RateLimiter::check($_SERVER['REMOTE_ADDR'] ?? '', RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS);
192
+
193
+ if ($endpoint['isProtected']) {
194
+ authorize($config, $endpointKey, $_SERVER['HTTP_X_API_KEY'] ?? '');
195
+ }
196
+
197
+ $rawBody = file_get_contents('php://input');
198
+
199
+ dispatch($endpoint['file'], [
200
+ 'method' => $method,
201
+ 'requestParams' => $endpoint['params'],
202
+ 'query' => $_GET,
203
+ 'body' => json_decode($rawBody, true) ?? [],
204
+ 'rawBody' => $rawBody,
205
+ 'headers' => function_exists('getallheaders') ? getallheaders() : [],
206
+ 'config' => $config,
207
+ ]);
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ const CONFIG_FILE = "config.js";
7
+ const EXAMPLE_CONFIG_FILE = "example.config.js";
8
+ const DEFAULT_ENVIRONMENT = "production";
9
+
10
+ function loadConfig() {
11
+ const backendRoot = path.join(__dirname, "..");
12
+ const configPath = path.join(backendRoot, CONFIG_FILE);
13
+
14
+ if (!fs.existsSync(configPath)) {
15
+ throw new Error(
16
+ `Missing ${CONFIG_FILE}. Copy ${EXAMPLE_CONFIG_FILE} to ${CONFIG_FILE} in ${backendRoot} and fill in your values.`,
17
+ );
18
+ }
19
+
20
+ return require(configPath);
21
+ }
22
+
23
+ function isDebug(config) {
24
+ return (config.APP_ENV ?? DEFAULT_ENVIRONMENT) !== DEFAULT_ENVIRONMENT;
25
+ }
26
+
27
+ function handleException(exception, config, Response) {
28
+ const debugMode = isDebug(config);
29
+
30
+ Response.error(
31
+ debugMode ? exception.message : "Internal server error",
32
+ 500,
33
+ debugMode ? { stack: exception.stack } : null,
34
+ );
35
+ }
36
+
37
+ module.exports = { loadConfig, isDebug, handleException };
@@ -0,0 +1,54 @@
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
+ const CONFIG_FILE = 'config.php';
9
+ const EXAMPLE_CONFIG_FILE = 'example.config.php';
10
+ const DEFAULT_ENVIRONMENT = 'production';
11
+
12
+ $backendRoot = dirname(__DIR__);
13
+ $configPath = $backendRoot . '/' . CONFIG_FILE;
14
+
15
+ if (!file_exists($configPath)) {
16
+ http_response_code(500);
17
+ header('Content-Type: application/json; charset=UTF-8');
18
+ echo json_encode([
19
+ 'status' => 'error',
20
+ 'message' => 'Missing ' . CONFIG_FILE . '. Copy ' . EXAMPLE_CONFIG_FILE . ' to ' . CONFIG_FILE . ' in ' . $backendRoot . ' and fill in your values.',
21
+ 'code' => 500,
22
+ ]);
23
+ exit;
24
+ }
25
+
26
+ $config = require $configPath;
27
+ $isDebug = ($config['APP_ENV'] ?? DEFAULT_ENVIRONMENT) !== DEFAULT_ENVIRONMENT;
28
+
29
+ set_exception_handler(function (Throwable $exception) use ($isDebug) {
30
+ Response::error(
31
+ $isDebug ? $exception->getMessage() : 'Internal server error',
32
+ 500,
33
+ $isDebug ? [
34
+ 'file' => $exception->getFile(),
35
+ 'line' => $exception->getLine(),
36
+ 'warning' => 'Error details are exposed. Set APP_ENV to "production" in config.php before publishing.',
37
+ ] : null
38
+ );
39
+ });
40
+
41
+ set_error_handler(function (int $severity, string $message, string $file, int $line) {
42
+ if (!(error_reporting() & $severity)) {
43
+ return false;
44
+ }
45
+ throw new ErrorException($message, 0, $severity, $file, $line);
46
+ });
47
+
48
+ if ($isDebug) {
49
+ ini_set('display_errors', '1');
50
+ error_reporting(E_ALL);
51
+ } else {
52
+ ini_set('display_errors', '0');
53
+ error_reporting(0);
54
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+
7
+ const CACHE_FOLDER = "cache";
8
+ const CACHE_PREFIX = "rl_";
9
+ const CACHE_EXTENSION = ".json";
10
+ const FOLDER_PERMISSIONS = 0o755;
11
+ const TOO_MANY_REQUESTS = 429;
12
+ const MILLISECONDS_PER_SECOND = 1000;
13
+
14
+ function createRateLimiter(Response, res) {
15
+ return {
16
+ check(ip, maxRequests, windowSeconds) {
17
+ const cacheDir = path.join(__dirname, "..", "..", CACHE_FOLDER);
18
+
19
+ if (!fs.existsSync(cacheDir)) {
20
+ fs.mkdirSync(cacheDir, { recursive: true, mode: FOLDER_PERMISSIONS });
21
+ }
22
+
23
+ const hash = crypto.createHash("md5").update(ip).digest("hex");
24
+ const cacheFile = path.join(
25
+ cacheDir,
26
+ CACHE_PREFIX + hash + CACHE_EXTENSION,
27
+ );
28
+ const now = Math.floor(Date.now() / MILLISECONDS_PER_SECOND);
29
+
30
+ let timestamps = [];
31
+
32
+ if (fs.existsSync(cacheFile)) {
33
+ try {
34
+ timestamps = JSON.parse(fs.readFileSync(cacheFile, "utf8")) || [];
35
+ } catch (error) {
36
+ timestamps = [];
37
+ }
38
+ }
39
+
40
+ timestamps = timestamps.filter(
41
+ (timestamp) => timestamp > now - windowSeconds,
42
+ );
43
+ timestamps.push(now);
44
+
45
+ fs.writeFileSync(cacheFile, JSON.stringify(timestamps));
46
+
47
+ if (timestamps.length > maxRequests) {
48
+ res.setHeader("Retry-After", String(windowSeconds));
49
+ Response.error("Too Many Requests", TOO_MANY_REQUESTS);
50
+ }
51
+ },
52
+ };
53
+ }
54
+
55
+ module.exports = { createRateLimiter };