nibula 2.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eleventy.js +0 -7
- package/CHANGELOG.md +44 -3
- package/bin/create.js +43 -45
- package/bin/nibula.js +0 -6
- package/nginx.conf +1 -1
- package/package.json +3 -7
- package/src/backend/.htaccess +2 -2
- package/src/backend/core/index.js +329 -0
- package/src/backend/core/index.php +207 -0
- package/src/backend/core/init.js +37 -0
- package/src/backend/core/init.php +54 -0
- package/src/backend/core/modules/RateLimiter.js +55 -0
- package/src/backend/core/modules/RateLimiter.php +39 -0
- package/src/backend/{_core → core}/modules/Response.js +15 -10
- package/src/backend/{_core → core}/modules/Response.php +14 -17
- package/src/backend/core/modules/Validate.js +56 -0
- package/src/backend/core/modules/Validate.php +52 -0
- package/src/backend/endpoints/protected/example-protected.js +39 -0
- package/src/backend/endpoints/protected/example-protected.php +47 -0
- package/src/backend/endpoints/public/example-public.js +39 -0
- package/src/backend/endpoints/public/example-public.php +47 -0
- package/src/backend/example.config.js +7 -6
- package/src/backend/example.config.php +8 -7
- package/src/backend/package.json +3 -3
- package/src/backend/web.config +2 -2
- package/src/frontend/hosting/.htaccess +1 -1
- package/src/frontend/hosting/web.config +1 -1
- package/src/frontend/layouts/base.njk +3 -7
- package/src/frontend/scss/{_global.scss → global.scss} +0 -2
- package/src/frontend/scss/modules/frameworks/_bootstrap.scss +0 -2
- package/src/frontend/scss/modules/frameworks/_bulma.scss +0 -2
- package/src/frontend/scss/pages/404.scss +0 -3
- package/src/frontend/scss/pages/homepage.scss +0 -3
- package/tools/res/templates/template.scss +0 -3
- package/src/backend/_core/index.js +0 -230
- package/src/backend/_core/index.php +0 -118
- package/src/backend/_core/init.js +0 -32
- package/src/backend/_core/init.php +0 -30
- package/src/backend/_core/modules/RateLimiter.js +0 -47
- package/src/backend/_core/modules/RateLimiter.php +0 -31
- package/src/backend/api/protected/example-protected.js +0 -16
- package/src/backend/api/protected/example-protected.php +0 -17
- package/src/backend/api/public/example-public.js +0 -16
- package/src/backend/api/public/example-public.php +0 -17
- package/src/frontend/scss/modules/frameworks/_foundation.scss +0 -138
- package/src/frontend/scss/modules/frameworks/_uikit.scss +0 -110
- package/tools/buildJs.js +0 -39
- /package/src/backend/{_core → core}/composer.json +0 -0
- /package/src/backend/{_core → core}/composer.lock +0 -0
|
@@ -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 };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
class RateLimiter
|
|
6
|
+
{
|
|
7
|
+
private const CACHE_FOLDER = '/../../cache';
|
|
8
|
+
private const CACHE_PREFIX = 'rl_';
|
|
9
|
+
private const CACHE_EXTENSION = '.json';
|
|
10
|
+
private const FOLDER_PERMISSIONS = 0755;
|
|
11
|
+
private const TOO_MANY_REQUESTS = 429;
|
|
12
|
+
|
|
13
|
+
public static function check(string $ip, int $maxRequests, int $windowSeconds): void
|
|
14
|
+
{
|
|
15
|
+
$cacheDir = __DIR__ . self::CACHE_FOLDER;
|
|
16
|
+
|
|
17
|
+
if (!is_dir($cacheDir)) {
|
|
18
|
+
mkdir($cacheDir, self::FOLDER_PERMISSIONS, true);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
$cacheFile = $cacheDir . '/' . self::CACHE_PREFIX . md5($ip) . self::CACHE_EXTENSION;
|
|
22
|
+
$now = time();
|
|
23
|
+
$timestamps = [];
|
|
24
|
+
|
|
25
|
+
if (file_exists($cacheFile)) {
|
|
26
|
+
$timestamps = json_decode(file_get_contents($cacheFile), true) ?: [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
$timestamps = array_filter($timestamps, fn($timestamp) => $timestamp > ($now - $windowSeconds));
|
|
30
|
+
$timestamps[] = $now;
|
|
31
|
+
|
|
32
|
+
file_put_contents($cacheFile, json_encode(array_values($timestamps)), LOCK_EX);
|
|
33
|
+
|
|
34
|
+
if (count($timestamps) > $maxRequests) {
|
|
35
|
+
header('Retry-After: ' . $windowSeconds);
|
|
36
|
+
Response::error('Too Many Requests', self::TOO_MANY_REQUESTS);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
const HALT = Symbol("RESPONSE_HALT");
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const STATUS_OK = 200;
|
|
6
|
+
const STATUS_BAD_REQUEST = 400;
|
|
7
|
+
const STATUS_NO_CONTENT = 204;
|
|
8
|
+
|
|
9
|
+
const JSON_CONTENT_TYPE = "application/json; charset=UTF-8";
|
|
8
10
|
|
|
9
11
|
function createResponse(res) {
|
|
10
12
|
return {
|
|
11
|
-
success(data = null, code =
|
|
13
|
+
success(data = null, code = STATUS_OK) {
|
|
12
14
|
res.statusCode = code;
|
|
13
|
-
res.setHeader("Content-Type",
|
|
15
|
+
res.setHeader("Content-Type", JSON_CONTENT_TYPE);
|
|
14
16
|
res.end(
|
|
15
|
-
|
|
17
|
+
JSON.stringify({
|
|
16
18
|
status: "success",
|
|
17
19
|
data: data,
|
|
18
20
|
}),
|
|
@@ -20,23 +22,26 @@ function createResponse(res) {
|
|
|
20
22
|
throw HALT;
|
|
21
23
|
},
|
|
22
24
|
|
|
23
|
-
error(message, code =
|
|
25
|
+
error(message, code = STATUS_BAD_REQUEST, details = null) {
|
|
24
26
|
res.statusCode = code;
|
|
25
|
-
res.setHeader("Content-Type",
|
|
27
|
+
res.setHeader("Content-Type", JSON_CONTENT_TYPE);
|
|
28
|
+
|
|
26
29
|
const body = {
|
|
27
30
|
status: "error",
|
|
28
31
|
message: message,
|
|
29
32
|
code: code,
|
|
30
33
|
};
|
|
34
|
+
|
|
31
35
|
if (details !== null) {
|
|
32
36
|
body.details = details;
|
|
33
37
|
}
|
|
34
|
-
|
|
38
|
+
|
|
39
|
+
res.end(JSON.stringify(body));
|
|
35
40
|
throw HALT;
|
|
36
41
|
},
|
|
37
42
|
|
|
38
43
|
noContent() {
|
|
39
|
-
res.statusCode =
|
|
44
|
+
res.statusCode = STATUS_NO_CONTENT;
|
|
40
45
|
res.end();
|
|
41
46
|
throw HALT;
|
|
42
47
|
},
|
|
@@ -2,48 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
declare(strict_types=1);
|
|
4
4
|
|
|
5
|
-
if (!defined('CORE_ACCESS')) {
|
|
6
|
-
$errorPage = $_SERVER['DOCUMENT_ROOT'] . '/404.html';
|
|
7
|
-
http_response_code(404);
|
|
8
|
-
if (file_exists($errorPage)) {
|
|
9
|
-
header('Content-Type: text/html; charset=UTF-8');
|
|
10
|
-
echo file_get_contents($errorPage);
|
|
11
|
-
} else {
|
|
12
|
-
echo "404 Not Found";
|
|
13
|
-
}
|
|
14
|
-
exit;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
5
|
class Response
|
|
18
6
|
{
|
|
19
|
-
|
|
7
|
+
private const STATUS_OK = 200;
|
|
8
|
+
private const STATUS_BAD_REQUEST = 400;
|
|
9
|
+
private const STATUS_NO_CONTENT = 204;
|
|
10
|
+
|
|
11
|
+
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
|
12
|
+
|
|
13
|
+
public static function success(mixed $data = null, int $code = self::STATUS_OK): never
|
|
20
14
|
{
|
|
21
15
|
http_response_code($code);
|
|
22
16
|
echo json_encode([
|
|
23
17
|
'status' => 'success',
|
|
24
18
|
'data' => $data,
|
|
25
|
-
],
|
|
19
|
+
], self::JSON_FLAGS);
|
|
26
20
|
exit;
|
|
27
21
|
}
|
|
28
22
|
|
|
29
|
-
public static function error(string $message, int $code =
|
|
23
|
+
public static function error(string $message, int $code = self::STATUS_BAD_REQUEST, mixed $details = null): never
|
|
30
24
|
{
|
|
31
25
|
http_response_code($code);
|
|
26
|
+
|
|
32
27
|
$body = [
|
|
33
28
|
'status' => 'error',
|
|
34
29
|
'message' => $message,
|
|
35
30
|
'code' => $code,
|
|
36
31
|
];
|
|
32
|
+
|
|
37
33
|
if ($details !== null) {
|
|
38
34
|
$body['details'] = $details;
|
|
39
35
|
}
|
|
40
|
-
|
|
36
|
+
|
|
37
|
+
echo json_encode($body, self::JSON_FLAGS);
|
|
41
38
|
exit;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
public static function noContent(): never
|
|
45
42
|
{
|
|
46
|
-
http_response_code(
|
|
43
|
+
http_response_code(self::STATUS_NO_CONTENT);
|
|
47
44
|
exit;
|
|
48
45
|
}
|
|
49
46
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const VALIDATION_STATUS = 422;
|
|
4
|
+
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
5
|
+
const INTEGER_PATTERN = /^\d+$/;
|
|
6
|
+
|
|
7
|
+
function createValidator(Response) {
|
|
8
|
+
const validator = {
|
|
9
|
+
required(value, field) {
|
|
10
|
+
if (value === undefined || value === null || value === "") {
|
|
11
|
+
Response.error(`The ${field} field is required`, VALIDATION_STATUS);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
integer(value, field) {
|
|
17
|
+
validator.required(value, field);
|
|
18
|
+
|
|
19
|
+
if (!INTEGER_PATTERN.test(String(value))) {
|
|
20
|
+
Response.error(
|
|
21
|
+
`The ${field} field must be a number`,
|
|
22
|
+
VALIDATION_STATUS,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return Number(value);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
email(value, field) {
|
|
29
|
+
validator.required(value, field);
|
|
30
|
+
|
|
31
|
+
if (!EMAIL_PATTERN.test(String(value))) {
|
|
32
|
+
Response.error(
|
|
33
|
+
`The ${field} field must be a valid email`,
|
|
34
|
+
VALIDATION_STATUS,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return String(value);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
minLength(value, field, length) {
|
|
41
|
+
validator.required(value, field);
|
|
42
|
+
|
|
43
|
+
if (String(value).length < length) {
|
|
44
|
+
Response.error(
|
|
45
|
+
`The ${field} field must be at least ${length} characters`,
|
|
46
|
+
VALIDATION_STATUS,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return String(value);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return validator;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { createValidator };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
class Validate
|
|
6
|
+
{
|
|
7
|
+
private const VALIDATION_STATUS = 422;
|
|
8
|
+
private const EMAIL_PATTERN = '/^[^@\s]+@[^@\s]+\.[^@\s]+$/';
|
|
9
|
+
private const INTEGER_PATTERN = '/^\d+$/';
|
|
10
|
+
|
|
11
|
+
public static function required(mixed $value, string $field): mixed
|
|
12
|
+
{
|
|
13
|
+
if ($value === null || $value === '') {
|
|
14
|
+
Response::error("The {$field} field is required", self::VALIDATION_STATUS);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return $value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public static function integer(mixed $value, string $field): int
|
|
21
|
+
{
|
|
22
|
+
self::required($value, $field);
|
|
23
|
+
|
|
24
|
+
if (!preg_match(self::INTEGER_PATTERN, (string) $value)) {
|
|
25
|
+
Response::error("The {$field} field must be a number", self::VALIDATION_STATUS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (int) $value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public static function email(mixed $value, string $field): string
|
|
32
|
+
{
|
|
33
|
+
self::required($value, $field);
|
|
34
|
+
|
|
35
|
+
if (!preg_match(self::EMAIL_PATTERN, (string) $value)) {
|
|
36
|
+
Response::error("The {$field} field must be a valid email", self::VALIDATION_STATUS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (string) $value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public static function minLength(mixed $value, string $field, int $length): string
|
|
43
|
+
{
|
|
44
|
+
self::required($value, $field);
|
|
45
|
+
|
|
46
|
+
if (mb_strlen((string) $value) < $length) {
|
|
47
|
+
Response::error("The {$field} field must be at least {$length} characters", self::VALIDATION_STATUS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (string) $value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export one function per HTTP method you want to support. Any other method
|
|
5
|
+
* gets an automatic 405, so you never have to check the method yourself.
|
|
6
|
+
*
|
|
7
|
+
* Each function receives the request context:
|
|
8
|
+
* method the HTTP method, as a string
|
|
9
|
+
* requestParams extra URL segments after the endpoint path
|
|
10
|
+
* query query string values: ?page=2 gives { page: '2' }
|
|
11
|
+
* body the JSON body, already parsed into an object
|
|
12
|
+
* rawBody the body as a raw string, if you need it
|
|
13
|
+
* headers the request headers
|
|
14
|
+
* config everything from config.js
|
|
15
|
+
* Response sends the answer
|
|
16
|
+
* Validate checks incoming values
|
|
17
|
+
*
|
|
18
|
+
* Response.success(data, code) and Response.error(message, code) send the
|
|
19
|
+
* answer and stop the request: nothing after them runs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
get: ({ requestParams, query, Response }) => {
|
|
24
|
+
Response.success({
|
|
25
|
+
message: "Endpoint is working",
|
|
26
|
+
params: requestParams,
|
|
27
|
+
query: query,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
post: ({ body, Response, Validate }) => {
|
|
32
|
+
// Validate stops the request with a 422 when a value is missing or
|
|
33
|
+
// malformed, so the returned value is always safe to use.
|
|
34
|
+
const name = Validate.required(body.name, "name");
|
|
35
|
+
const email = Validate.email(body.email, "email");
|
|
36
|
+
|
|
37
|
+
Response.success({ name, email }, 201);
|
|
38
|
+
},
|
|
39
|
+
};
|