nibula 2.4.7 → 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 +48 -2
- 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/config/settings.json +1 -13
- package/tools/lib/pagesData.js +13 -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
package/.eleventy.js
CHANGED
|
@@ -109,13 +109,6 @@ module.exports = function (eleventyConfig) {
|
|
|
109
109
|
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js": "js/bootstrap.bundle.min.js",
|
|
110
110
|
"node_modules/bootstrap-icons/font/fonts": "css/fonts",
|
|
111
111
|
|
|
112
|
-
// Foundation
|
|
113
|
-
// "node_modules/foundation-sites/dist/js/foundation.min.js": "js/foundation.min.js",
|
|
114
|
-
|
|
115
|
-
// UIkit
|
|
116
|
-
// "node_modules/uikit/dist/js/uikit.min.js": "js/uikit.min.js",
|
|
117
|
-
// "node_modules/uikit/dist/js/uikit-icons.min.js": "js/uikit-icons.min.js",
|
|
118
|
-
|
|
119
112
|
// Bulma — CSS only, no JS passthrough needed
|
|
120
113
|
});
|
|
121
114
|
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,52 @@ 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
|
+
## [3.0.0] - 2026-09-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Endpoints now export one function per HTTP method instead of checking the method by hand. Unsupported methods get an automatic 405, and the file itself shows at a glance which methods it answers. The old single-function form still works.
|
|
12
|
+
- A `Validate` module with `required`, `integer`, `email` and `minLength`. Each rule either returns the value or stops the request with a 422 and a readable message, so endpoints no longer repeat the same checks.
|
|
13
|
+
- Endpoints receive `query`, `body`, `rawBody` and `headers` in both backends. The PHP side previously left the body to the endpoint, which meant every project reimplemented the same `json_decode`.
|
|
14
|
+
- A `Debug-Mode` header on every response when `APP_ENV` is not `production`, so a site left in debug mode is visible without reading the config.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- **BREAKING** Renamed `src/backend/_core` to `src/backend/core`. The underscore prefix carried no meaning: no tool interpreted it, it only made the path noisier than its siblings.
|
|
18
|
+
- **BREAKING** Renamed `src/backend/api` to `src/backend/endpoints`. The folder holds route files, while `/api` is the public URL prefix, and using one name for two concepts was the main source of confusion when reading the front controller. Endpoints are still reached at `domain/api/endpoint`.
|
|
19
|
+
- **BREAKING** A missing API key now returns 401 instead of 403. The previous code said "forbidden" for a request that was never authenticated.
|
|
20
|
+
- **BREAKING** The backend no longer starts without `config.js` or `config.php`. It used to fall back to the example file, which ships a publicly known API key and allows every origin.
|
|
21
|
+
- **BREAKING** The bundled `nginx.conf`, `.htaccess` and `web.config` now point at `backend/core` instead of `backend/_core`. Any custom server rule referring to the old path needs updating.
|
|
22
|
+
- **BREAKING** Removed the `build-js` command. Projects now call `esbuild` directly in their `build:js` and `serve:js` scripts, with the entry pattern written out in full. `nib` is for the commands it offers, not a wrapper around the build.
|
|
23
|
+
- **BREAKING** The global stylesheet is loaded by the layout instead of being imported by each page. `_global.scss` is now `global.scss`, so Sass compiles it into `out/css/global.css`, and `base.njk` links it before the page's own file. Until now every page's CSS carried a full copy of the framework and of every module, so a visitor reading three pages downloaded the same thing three times with no way to cache it.
|
|
24
|
+
- **BREAKING** Page stylesheets no longer start with `@import "../global"`. A new page opens with the `@use "../root"` line and nothing else, since everything shared now arrives from the layout.
|
|
25
|
+
- **BREAKING** Removed Foundation and UIkit from the frameworks offered at project creation. Four libraries covering the same ground made the choice harder without covering a case the other two miss, and each one carried a module file, a set of markers and a dependency in every project.
|
|
26
|
+
- Endpoints only receive the documented request values. They previously saw every local variable of the front controller by accident, which made internal renames a breaking change without anyone noticing.
|
|
27
|
+
- `RateLimiter` is now created per request like `Response`, instead of taking the response helpers as trailing arguments.
|
|
28
|
+
- The PHP backend removes the `X-Powered-By` header, which exposed the exact PHP version on every response.
|
|
29
|
+
- `Response.php` no longer carries its own access guard and 404 fallback. Every request already goes through the front controller, so the guard could never fire, and it duplicated error-page logic inside a module that only formats responses.
|
|
30
|
+
- Removed the `glob` dependency from new projects. It existed only to expand the entry pattern before handing it to esbuild, which expands it by itself.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- A custom endpoint key or origin list set to an empty value no longer falls back to the general one.
|
|
34
|
+
- The PHP exception handler no longer captures the config before it exists, which used to hide the real cause of startup errors behind a generic 500.
|
|
35
|
+
|
|
36
|
+
### Notes
|
|
37
|
+
- **To migrate the stylesheets:** rename `_global.scss` to `global.scss`, remove `@import "../global"` from every file in `scss/pages/`, and add the global stylesheet link to `base.njk` above the page one.
|
|
38
|
+
- **`nib build-js` no longer exists.** A project created before this release has it in its `package.json` and its build will fail after updating. Replace the two scripts with:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
"build:js": "esbuild \"src/frontend/js/pages/*.js\" --bundle --outdir=out/js/pages --minify",
|
|
42
|
+
"serve:js": "esbuild \"src/frontend/js/pages/*.js\" --bundle --outdir=out/js/pages --watch"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A TypeScript project uses `src/frontend/ts/pages/*.ts` instead.
|
|
46
|
+
|
|
47
|
+
- **Foundation and UIkit are no longer offered.** An existing project that uses one keeps working: nothing is removed from it. A new project that wants them can install the package and add the import by hand, the same way any other library works.
|
|
48
|
+
|
|
49
|
+
## [2.5.0] - 2026-09-07
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- The data record a new page gets in `pages.json` comes from `tools/res/templates/template.json` instead of being written inside `settings.json`. It's content, like the route, the stylesheet and the script, and it now sits with the other three templates rather than in the CLI's configuration.
|
|
53
|
+
|
|
8
54
|
## [2.4.7] - 2026-09-04
|
|
9
55
|
|
|
10
56
|
### Changed
|
|
@@ -19,8 +65,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
19
65
|
|
|
20
66
|
### Changed
|
|
21
67
|
|
|
22
|
-
- Restored `404.scss` **.not-found** selector to center the not found text
|
|
23
|
-
- Removed all comments in backend files
|
|
68
|
+
- Restored `404.scss` **.not-found** selector to center the not found text.
|
|
69
|
+
- Removed all comments in backend files.
|
|
24
70
|
|
|
25
71
|
## [2.4.5] - 2026-08-26
|
|
26
72
|
|
package/bin/create.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const readline = require('readline');
|
|
6
|
+
const settings = require('../tools/config/settings.json');
|
|
6
7
|
const { writeSync } = require('fs');
|
|
7
8
|
const { spawnSync } = require('child_process');
|
|
8
9
|
const { color } = require('../tools/lib/colors');
|
|
@@ -23,8 +24,6 @@ const LANGUAGE = Object.freeze({
|
|
|
23
24
|
const FRAMEWORK = Object.freeze({
|
|
24
25
|
BOOTSTRAP: 'bootstrap',
|
|
25
26
|
BULMA: 'bulma',
|
|
26
|
-
FOUNDATION: 'foundation',
|
|
27
|
-
UIKIT: 'uikit',
|
|
28
27
|
NONE: 'none',
|
|
29
28
|
});
|
|
30
29
|
|
|
@@ -41,21 +40,19 @@ const COMMENT_STYLE = Object.freeze({
|
|
|
41
40
|
// ── CHOICES ──────────────────────────────────────────────────────────────────
|
|
42
41
|
|
|
43
42
|
const LANGUAGE_CHOICES = [
|
|
44
|
-
{ label: 'JavaScript
|
|
45
|
-
{ label: 'TypeScript',
|
|
43
|
+
{ label: 'JavaScript', note: 'simpler to start', value: LANGUAGE.JAVASCRIPT },
|
|
44
|
+
{ label: 'TypeScript', note: 'catches mistakes', value: LANGUAGE.TYPESCRIPT },
|
|
46
45
|
];
|
|
47
46
|
|
|
48
47
|
const FRAMEWORK_CHOICES = [
|
|
49
|
-
{ label: 'Bootstrap
|
|
50
|
-
{ label: 'Bulma',
|
|
51
|
-
{ label: '
|
|
52
|
-
{ label: 'UIkit', value: FRAMEWORK.UIKIT },
|
|
53
|
-
{ label: 'None', value: FRAMEWORK.NONE },
|
|
48
|
+
{ label: 'Bootstrap', note: 'the most common', value: FRAMEWORK.BOOTSTRAP },
|
|
49
|
+
{ label: 'Bulma', note: 'lighter, no JS', value: FRAMEWORK.BULMA },
|
|
50
|
+
{ label: 'None', value: FRAMEWORK.NONE },
|
|
54
51
|
];
|
|
55
52
|
|
|
56
53
|
const BACKEND_CHOICES = [
|
|
57
|
-
{ label: 'Node.js
|
|
58
|
-
{ label: 'PHP
|
|
54
|
+
{ label: 'Node.js', note: 'needs a VPS', value: BACKEND.NODE },
|
|
55
|
+
{ label: 'PHP', note: 'any hosting', value: BACKEND.PHP },
|
|
59
56
|
];
|
|
60
57
|
|
|
61
58
|
// Runtime dependencies for the Node backend, read from the backend's own
|
|
@@ -109,16 +106,6 @@ const FRAMEWORKS = {
|
|
|
109
106
|
njk: [],
|
|
110
107
|
eleventy: [],
|
|
111
108
|
},
|
|
112
|
-
[FRAMEWORK.FOUNDATION]: {
|
|
113
|
-
scss: 'foundation',
|
|
114
|
-
njk: ['/js/foundation.min.js'],
|
|
115
|
-
eleventy: ['foundation-sites/dist/js/foundation.min.js'],
|
|
116
|
-
},
|
|
117
|
-
[FRAMEWORK.UIKIT]: {
|
|
118
|
-
scss: 'uikit',
|
|
119
|
-
njk: ['/js/uikit.min.js', '/js/uikit-icons.min.js'],
|
|
120
|
-
eleventy: ['uikit/dist/js/uikit.min.js', 'uikit/dist/js/uikit-icons.min.js'],
|
|
121
|
-
},
|
|
122
109
|
[FRAMEWORK.NONE]: {
|
|
123
110
|
scss: null,
|
|
124
111
|
njk: [],
|
|
@@ -130,7 +117,7 @@ const FRAMEWORKS = {
|
|
|
130
117
|
|
|
131
118
|
const GITIGNORE_CONTENT = `
|
|
132
119
|
node_modules/
|
|
133
|
-
src/backend/
|
|
120
|
+
src/backend/core/vendor/
|
|
134
121
|
out/
|
|
135
122
|
src/backend/config.php
|
|
136
123
|
src/backend/config.js
|
|
@@ -143,18 +130,6 @@ const PROJECT_PACKAGE = {
|
|
|
143
130
|
version: '0.0.0',
|
|
144
131
|
private: true,
|
|
145
132
|
outputDir: 'out',
|
|
146
|
-
"scripts": {
|
|
147
|
-
"build:css": "sass src/frontend/scss:out/css --no-source-map --style=compressed --quiet --load-path=node_modules",
|
|
148
|
-
"build:js": "nib build-js",
|
|
149
|
-
"build:11ty": "eleventy",
|
|
150
|
-
"build": "npm run clean && npm run build:css && npm run build:js && npm run build:11ty",
|
|
151
|
-
"serve:css": "sass --watch src/frontend/scss:out/css --no-source-map --quiet --load-path=node_modules",
|
|
152
|
-
"serve:js": "nib build-js --watch",
|
|
153
|
-
"serve:11ty": "eleventy --serve --quiet",
|
|
154
|
-
"clean": "nib clean",
|
|
155
|
-
"serve": "npm run clean && concurrently \"npm run serve:11ty\" \"npm run serve:css\" \"npm run serve:js\"",
|
|
156
|
-
"assistant": "nib cli"
|
|
157
|
-
},
|
|
158
133
|
dependencies: {
|
|
159
134
|
'@11ty/eleventy': '^3.1.6',
|
|
160
135
|
'@11ty/eleventy-img': '^7.0.0',
|
|
@@ -162,11 +137,8 @@ const PROJECT_PACKAGE = {
|
|
|
162
137
|
'bootstrap': '^5.3.8',
|
|
163
138
|
'bootstrap-icons': '^1.13.1',
|
|
164
139
|
'bulma': '^1.0.4',
|
|
165
|
-
'foundation-sites': '^6.9.0',
|
|
166
|
-
'glob': '^13.0.6',
|
|
167
140
|
'markdown-it-anchor': '^9.2.1',
|
|
168
141
|
'markdown-it-attrs': '^5.0.1',
|
|
169
|
-
'uikit': '^3.25.21',
|
|
170
142
|
},
|
|
171
143
|
devDependencies: {
|
|
172
144
|
'nibula': `^${SELF_VERSION}`,
|
|
@@ -178,6 +150,24 @@ const PROJECT_PACKAGE = {
|
|
|
178
150
|
|
|
179
151
|
// ── HELPERS ───────────────────────────────────────────────────────────────────
|
|
180
152
|
|
|
153
|
+
function projectScripts(language) {
|
|
154
|
+
const { pages, extension } = settings.languages[language];
|
|
155
|
+
const entries = `${pages}/*.${extension}`;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
'build:css': 'sass src/frontend/scss:out/css --no-source-map --style=compressed --quiet --load-path=node_modules',
|
|
159
|
+
'build:js': `esbuild "${entries}" --bundle --outdir=out/js/pages --minify`,
|
|
160
|
+
'build:11ty': 'eleventy',
|
|
161
|
+
'build': 'npm run clean && npm run build:css && npm run build:js && npm run build:11ty',
|
|
162
|
+
'serve:css': 'sass --watch src/frontend/scss:out/css --no-source-map --quiet --load-path=node_modules',
|
|
163
|
+
'serve:js': `esbuild "${entries}" --bundle --outdir=out/js/pages --watch`,
|
|
164
|
+
'serve:11ty': 'eleventy --serve --quiet',
|
|
165
|
+
'clean': 'nib clean',
|
|
166
|
+
'serve': 'npm run clean && concurrently "npm run serve:11ty" "npm run serve:css" "npm run serve:js"',
|
|
167
|
+
'assistant': 'nib cli',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
181
171
|
function log(msg) {
|
|
182
172
|
writeSync(1, msg + '\n');
|
|
183
173
|
}
|
|
@@ -300,7 +290,7 @@ function njkUncomment(content, marker) {
|
|
|
300
290
|
}
|
|
301
291
|
|
|
302
292
|
function installDependencies(backend) {
|
|
303
|
-
const backendCore = path.join(targetDir, 'src', 'backend', '
|
|
293
|
+
const backendCore = path.join(targetDir, 'src', 'backend', 'core');
|
|
304
294
|
|
|
305
295
|
log(`${color.blue}\n>> Installing Node modules...${color.reset}`);
|
|
306
296
|
const npm = spawnSync('npm', ['install'], {
|
|
@@ -335,7 +325,7 @@ function installDependencies(backend) {
|
|
|
335
325
|
|
|
336
326
|
if (probe.status !== 0) {
|
|
337
327
|
log('\n(!) Composer not found — skipping backend dependencies.');
|
|
338
|
-
log(' Install Composer, then run: cd src/backend/
|
|
328
|
+
log(' Install Composer, then run: cd src/backend/core && composer install\n');
|
|
339
329
|
return true;
|
|
340
330
|
}
|
|
341
331
|
|
|
@@ -354,7 +344,7 @@ function installDependencies(backend) {
|
|
|
354
344
|
function applyFramework(framework) {
|
|
355
345
|
const config = FRAMEWORKS[framework];
|
|
356
346
|
|
|
357
|
-
const globalScssPath = path.join(targetDir, 'src/frontend/scss/
|
|
347
|
+
const globalScssPath = path.join(targetDir, 'src/frontend/scss/global.scss');
|
|
358
348
|
if (fs.existsSync(globalScssPath)) {
|
|
359
349
|
let content = fs.readFileSync(globalScssPath, 'utf8');
|
|
360
350
|
ALL_FRAMEWORKS.forEach(fw => {
|
|
@@ -395,13 +385,20 @@ function askChoice(question, choices) {
|
|
|
395
385
|
|
|
396
386
|
log(`\n>> ${question} (Use arrow keys and press Enter):\n`);
|
|
397
387
|
|
|
388
|
+
const labelWidth = Math.max(...choices.map((choice) => choice.label.length));
|
|
389
|
+
|
|
398
390
|
const render = (firstTime = false) => {
|
|
399
391
|
if (!firstTime) process.stdout.write(`\x1B[${choices.length}A`);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
392
|
+
|
|
393
|
+
const output = choices.map((choice, index) => {
|
|
394
|
+
const selected = index === selectedIndex;
|
|
395
|
+
const symbol = selected ? `${color.cyan}◉` : '*';
|
|
396
|
+
const label = selected ? `${choice.label}${color.reset}` : choice.label;
|
|
397
|
+
const note = choice.note ? `${color.dim} — ${choice.note}${color.reset}` : '';
|
|
398
|
+
|
|
399
|
+
return ` ${symbol} ${label}${note}\x1B[K\n`;
|
|
400
|
+
}).join('');
|
|
401
|
+
|
|
405
402
|
process.stdout.write(output);
|
|
406
403
|
};
|
|
407
404
|
|
|
@@ -478,6 +475,7 @@ async function init() {
|
|
|
478
475
|
// Build the project package.json. Clone the shared dependency maps so we
|
|
479
476
|
// never mutate the PROJECT_PACKAGE constant.
|
|
480
477
|
const pkg = { ...PROJECT_PACKAGE };
|
|
478
|
+
pkg.scripts = projectScripts(language);
|
|
481
479
|
pkg.dependencies = { ...PROJECT_PACKAGE.dependencies };
|
|
482
480
|
pkg.devDependencies = { ...PROJECT_PACKAGE.devDependencies };
|
|
483
481
|
|
package/bin/nibula.js
CHANGED
|
@@ -16,7 +16,6 @@ const [, , cmd, ...rest] = process.argv;
|
|
|
16
16
|
|
|
17
17
|
const CREATE = path.join(__dirname, 'create.js');
|
|
18
18
|
const ASSISTANT = path.join(__dirname, '..', 'tools', 'assistant.js');
|
|
19
|
-
const BUILDJS = path.join(__dirname, '..', 'tools', 'buildJs.js');
|
|
20
19
|
const CLEAN = path.join(__dirname, '..', 'tools', 'cleanOutput.js');
|
|
21
20
|
|
|
22
21
|
const REGISTRY = 'https://registry.npmjs.org/nibula/latest';
|
|
@@ -266,11 +265,6 @@ async function main() {
|
|
|
266
265
|
runNpm(root, 'build');
|
|
267
266
|
break;
|
|
268
267
|
}
|
|
269
|
-
case 'build-js': {
|
|
270
|
-
enterProject();
|
|
271
|
-
run(BUILDJS, rest);
|
|
272
|
-
break;
|
|
273
|
-
}
|
|
274
268
|
case 'clean': {
|
|
275
269
|
enterProject();
|
|
276
270
|
run(CLEAN, []);
|
package/nginx.conf
CHANGED
|
@@ -100,7 +100,7 @@ server {
|
|
|
100
100
|
location @php_backend {
|
|
101
101
|
include fastcgi_params;
|
|
102
102
|
|
|
103
|
-
fastcgi_param SCRIPT_FILENAME $document_root/backend/
|
|
103
|
+
fastcgi_param SCRIPT_FILENAME $document_root/backend/core/index.php;
|
|
104
104
|
fastcgi_pass unix:/run/php/php-fpm.sock;
|
|
105
105
|
}
|
|
106
106
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nibula",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
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",
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
"seo-optimization",
|
|
22
22
|
"ssg",
|
|
23
23
|
"static-site-generator",
|
|
24
|
-
"typescript"
|
|
25
|
-
"uikit"
|
|
24
|
+
"typescript"
|
|
26
25
|
],
|
|
27
26
|
"author": "Michele Garofalo",
|
|
28
27
|
"license": "Apache-2.0",
|
|
@@ -62,11 +61,8 @@
|
|
|
62
61
|
"bulma": "^1.0.4",
|
|
63
62
|
"concurrently": "^9.2.4",
|
|
64
63
|
"esbuild": "^0.28.2",
|
|
65
|
-
"foundation-sites": "^6.9.0",
|
|
66
|
-
"glob": "^13.0.6",
|
|
67
64
|
"markdown-it-anchor": "^9.2.1",
|
|
68
65
|
"markdown-it-attrs": "^5.0.1",
|
|
69
|
-
"sass": "^1.103.1"
|
|
70
|
-
"uikit": "^3.25.21"
|
|
66
|
+
"sass": "^1.103.1"
|
|
71
67
|
}
|
|
72
68
|
}
|
package/src/backend/.htaccess
CHANGED
|
@@ -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;
|