@seip/blue-bird 0.6.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/.env_example +38 -0
- package/AGENTS.md +276 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/backend/index.js +21 -0
- package/backend/routes/api.js +53 -0
- package/backend/routes/frontend.js +29 -0
- package/core/app.js +397 -0
- package/core/auth.js +180 -0
- package/core/cache.js +72 -0
- package/core/cli/docker.js +319 -0
- package/core/cli/init.js +130 -0
- package/core/cli/route.js +43 -0
- package/core/cli/swagger.js +40 -0
- package/core/config.js +52 -0
- package/core/debug.js +249 -0
- package/core/logger.js +100 -0
- package/core/middleware.js +27 -0
- package/core/router.js +148 -0
- package/core/seo.js +113 -0
- package/core/swagger.js +40 -0
- package/core/template.js +254 -0
- package/core/upload.js +77 -0
- package/core/validate.js +380 -0
- package/docker/Dockerfile +25 -0
- package/docker-compose.yml +70 -0
- package/frontend/public/favicon.ico +0 -0
- package/frontend/public/js/spa.js +183 -0
- package/frontend/public/js/tailwind.js +8 -0
- package/frontend/templates/about.html +101 -0
- package/frontend/templates/index.html +140 -0
- package/package.json +59 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Router from "@seip/blue-bird/core/router.js";
|
|
2
|
+
import Validator from "@seip/blue-bird/core/validate.js";
|
|
3
|
+
import Cache from "@seip/blue-bird/core/cache.js";
|
|
4
|
+
import Auth from "@seip/blue-bird/core/auth.js"
|
|
5
|
+
|
|
6
|
+
const routerApiExample = new Router("/api");
|
|
7
|
+
|
|
8
|
+
routerApiExample.get("/users", (req, res) => {
|
|
9
|
+
const users = [
|
|
10
|
+
{
|
|
11
|
+
name: "John Doe",
|
|
12
|
+
email: "john.doe@example.com",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "Jane Doe2",
|
|
16
|
+
email: "jane.doe2@example.com",
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
res.json(users);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const loginSchema = {
|
|
23
|
+
email: { required: true, email: true },
|
|
24
|
+
password: { required: true, min: 6 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const loginValidator = new Validator(loginSchema);
|
|
28
|
+
|
|
29
|
+
routerApiExample.post("/login", loginValidator.middleware(), (req, res) => {
|
|
30
|
+
res.json({ message: "Login successful", body: req.body });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
routerApiExample.get("/cache", Cache.middleware(), async (req, res) => {
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
35
|
+
res.json({ message: "Cache successful" });
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
routerApiExample.get("/auth_generate", async (req, res) => {
|
|
39
|
+
const token = await Auth.login(res, { id: 1, name: "John Doe" })
|
|
40
|
+
res.json({ message: "Auth successful", token });
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
routerApiExample.get("/auth_logout", async (req, res) => {
|
|
44
|
+
await Auth.logout(res)
|
|
45
|
+
res.json({ message: "Auth successful" });
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
routerApiExample.get("/auth_verify", Auth.protect(), (req, res) => {
|
|
49
|
+
const userInfo = req.user
|
|
50
|
+
res.json({ message: "Auth successful", user: userInfo });
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export default routerApiExample;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Router from "@seip/blue-bird/core/router.js";
|
|
2
|
+
import Template from "@seip/blue-bird/core/template.js";
|
|
3
|
+
import App from "@seip/blue-bird/core/app.js";
|
|
4
|
+
|
|
5
|
+
const routerFrontendExample = new Router("/", { seo: true });
|
|
6
|
+
|
|
7
|
+
routerFrontendExample.use(App.helmet());
|
|
8
|
+
|
|
9
|
+
routerFrontendExample.get("/", (req, res) => {
|
|
10
|
+
return Template.render(res, "index", {
|
|
11
|
+
metaTags: {
|
|
12
|
+
titleMeta: "Home - Blue Bird",
|
|
13
|
+
descriptionMeta: "Welcome to Blue Bird Framework",
|
|
14
|
+
keywordsMeta: "blue bird, framework, express"
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
routerFrontendExample.get("/about", (req, res) => {
|
|
20
|
+
return Template.render(res, "about", {
|
|
21
|
+
metaTags: {
|
|
22
|
+
titleMeta: "About - Blue Bird",
|
|
23
|
+
descriptionMeta: "About Blue Bird Framework",
|
|
24
|
+
keywordsMeta: "about, blue bird, framework"
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export default routerFrontendExample;
|
package/core/app.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import helmet from "helmet";
|
|
7
|
+
import cookieParser from "cookie-parser";
|
|
8
|
+
import rateLimit from "express-rate-limit";
|
|
9
|
+
import compression from "compression";
|
|
10
|
+
import Config from "./config.js";
|
|
11
|
+
import Logger from "./logger.js";
|
|
12
|
+
import Debug from "./debug.js";
|
|
13
|
+
import Template from "./template.js";
|
|
14
|
+
import SEO from "./seo.js";
|
|
15
|
+
|
|
16
|
+
const __dirname = Config.dirname();
|
|
17
|
+
const props = Config.props();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Main Application class to manage Express server, routes, and middlewares.
|
|
21
|
+
*/
|
|
22
|
+
class App {
|
|
23
|
+
/**
|
|
24
|
+
* Initializes the App instance with the provided options.
|
|
25
|
+
* @param {Object} [options] - Configuration options for the application.
|
|
26
|
+
* @param {Array<{path: string, router: import('express').Router}>} [options.routes=[]] - Array of route objects containing path and router components.
|
|
27
|
+
* @param {Object} [options.cors={}] - CORS configuration options.
|
|
28
|
+
* @param {Array<Function>} [options.middlewares=[]] - Array of middleware functions to be applied.
|
|
29
|
+
* @param {number|string} [options.port=3000] - Server port.
|
|
30
|
+
* @param {string} [options.host="http://localhost"] - Server host URL.
|
|
31
|
+
* @param {boolean} [options.logger=true] - Whether to enable the request logger.
|
|
32
|
+
* @param {boolean} [options.notFound=true] - Whether to enable the default 404 handler.
|
|
33
|
+
* @param {boolean} [options.json=true] - Whether to enable JSON body parsing.
|
|
34
|
+
* @param {boolean} [options.urlencoded=true] - Whether to enable URL-encoded body parsing.
|
|
35
|
+
* @param {Object} [options.static={path: null, options: {}}] - Static file configuration.
|
|
36
|
+
* @param {boolean} [options.cookieParser=true] - Whether to enable cookie parsing.
|
|
37
|
+
* @param {boolean|Object} [options.rateLimit=false] - Enable global rate limiting.
|
|
38
|
+
* @param {boolean|Object} [options.swagger=false] - Enable swagger.
|
|
39
|
+
* @param {boolean} [options.compression=true] - Enable Gzip compression.
|
|
40
|
+
* @example
|
|
41
|
+
* const app = new App({
|
|
42
|
+
* routes: [],
|
|
43
|
+
* cors: {},
|
|
44
|
+
* middlewares: [],
|
|
45
|
+
* port: 3000,
|
|
46
|
+
* host: "http://localhost",
|
|
47
|
+
* logger: true,
|
|
48
|
+
* notFound: true,
|
|
49
|
+
* json: true,
|
|
50
|
+
* urlencoded: true,
|
|
51
|
+
* static: { path: "public", options: {} },
|
|
52
|
+
* cookieParser: true,
|
|
53
|
+
* rateLimit: { windowMs: 10 * 60 * 1000, max: 50 },
|
|
54
|
+
* swagger: {
|
|
55
|
+
* info: { title: "Blue Bird API", version: "1.0.0", description: "API Documentation" },
|
|
56
|
+
* url: "http://localhost:8000"
|
|
57
|
+
* },
|
|
58
|
+
* compression: true
|
|
59
|
+
* });
|
|
60
|
+
*/
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
this.app = express();
|
|
63
|
+
this.routes = options.routes || [];
|
|
64
|
+
this.cors = options.cors || {};
|
|
65
|
+
this.middlewares = options.middlewares || [];
|
|
66
|
+
this.port = options.port || props.port;
|
|
67
|
+
this.host = options.host || props.host;
|
|
68
|
+
this.appUrl = options.appUrl || props.appUrl;
|
|
69
|
+
this.logger = options.logger ?? false;
|
|
70
|
+
this.notFound = options.notFound ?? true;
|
|
71
|
+
this.json = options.json ?? true;
|
|
72
|
+
this.urlencoded = options.urlencoded ?? true;
|
|
73
|
+
this.static = options.static || props.static;
|
|
74
|
+
this.cookieParser = options.cookieParser ?? true;
|
|
75
|
+
this.rateLimit = options.rateLimit ?? false;
|
|
76
|
+
this.swagger = options.swagger ?? false;
|
|
77
|
+
this.compression = options.compression ?? true;
|
|
78
|
+
this.loggerInstance = new Logger();
|
|
79
|
+
/** @type {Set<import('http').ServerResponse>} */
|
|
80
|
+
this._hotReloadClients = new Set();
|
|
81
|
+
this._ready = this._dispatch();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Registers a custom middleware or module in the Express application.
|
|
86
|
+
* @param {Function|import('express').Router} record - The middleware function or Express router to register.
|
|
87
|
+
* @example
|
|
88
|
+
* app.use((req, res, next) => {
|
|
89
|
+
* console.log("Middleware");
|
|
90
|
+
* next();
|
|
91
|
+
* });
|
|
92
|
+
*/
|
|
93
|
+
use(record) {
|
|
94
|
+
this.app.use(record);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sets a configuration value in the Express application.
|
|
99
|
+
* @param {string} key - The configuration key.
|
|
100
|
+
* @param {*} value - The value to set for the configuration key.
|
|
101
|
+
*/
|
|
102
|
+
set(key, value) {
|
|
103
|
+
this.app.set(key, value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bootstraps the application by configuring global middlewares and routes.
|
|
108
|
+
* @private
|
|
109
|
+
*/
|
|
110
|
+
async _dispatch() {
|
|
111
|
+
if (this.compression) this.app.use(compression());
|
|
112
|
+
if (this.json) this.app.use(express.json());
|
|
113
|
+
if (this.urlencoded) this.app.use(express.urlencoded({ extended: true }));
|
|
114
|
+
if (this.cookieParser) this.app.use(cookieParser());
|
|
115
|
+
|
|
116
|
+
this.app.use((req, res, next) => {
|
|
117
|
+
req.lang = req.query?.lang || req.body?.lang || req.cookies?.lang || "en";
|
|
118
|
+
res.locals.lang = req.lang;
|
|
119
|
+
next();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (this.static.path)
|
|
123
|
+
this.app.use(
|
|
124
|
+
express.static(
|
|
125
|
+
path.join(__dirname, this.static.path),
|
|
126
|
+
{ ...this.static.options, setHeaders: (res) => {
|
|
127
|
+
res.setHeader("X-Powered-By", "Blue Bird");
|
|
128
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
129
|
+
}},
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
this.app.use(cors(this.cors));
|
|
134
|
+
if (this.rateLimit) {
|
|
135
|
+
if (!this.app.get("trust proxy")) {
|
|
136
|
+
this.app.set("trust proxy", 1);
|
|
137
|
+
}
|
|
138
|
+
const defaultRateLimit = {
|
|
139
|
+
windowMs: 15 * 60 * 1000,
|
|
140
|
+
max: 500,
|
|
141
|
+
standardHeaders: true,
|
|
142
|
+
legacyHeaders: false,
|
|
143
|
+
message: {
|
|
144
|
+
success: false,
|
|
145
|
+
message: "Too many requests, please try again later.",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const optionsRateLimiter = {
|
|
149
|
+
...defaultRateLimit,
|
|
150
|
+
...(typeof this.rateLimit === "object" ? this.rateLimit : {}),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (props.debug) {
|
|
154
|
+
optionsRateLimiter.skip = (req) => req.path.startsWith("/debug");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const limiter = rateLimit(optionsRateLimiter);
|
|
158
|
+
this.app.use(limiter);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.middlewares.forEach((middleware) => {
|
|
162
|
+
this.app.use(middleware);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (this.logger || props.debug) this._middlewareLogger(this.logger);
|
|
166
|
+
|
|
167
|
+
this.app.use((req, res, next) => {
|
|
168
|
+
res.setHeader("X-Powered-By", "Blue Bird");
|
|
169
|
+
next();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (props.debug) {
|
|
173
|
+
Debug.middlewareMetrics(this.app);
|
|
174
|
+
this._setupHotReload();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (this.swagger) {
|
|
178
|
+
const { default: Swagger } = await import("./swagger.js");
|
|
179
|
+
const defaultSwaggerOptions = {
|
|
180
|
+
info: {
|
|
181
|
+
title: "Blue Bird API",
|
|
182
|
+
version: "1.0.0",
|
|
183
|
+
description: "Blue Bird Framework API Documentation",
|
|
184
|
+
},
|
|
185
|
+
url: this.appUrl ? this.appUrl : `${this.host}:${this.port}`,
|
|
186
|
+
route: "/docs",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const swaggerOptions = {
|
|
190
|
+
...defaultSwaggerOptions,
|
|
191
|
+
...(typeof this.swagger === "object" ? this.swagger : {}),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
Swagger.init(this.app, swaggerOptions);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this._dispatchRoutes();
|
|
198
|
+
|
|
199
|
+
SEO.registerEndpoints(this.app);
|
|
200
|
+
|
|
201
|
+
if (this.notFound) this._notFoundDefault();
|
|
202
|
+
|
|
203
|
+
this._errorHandler();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Sets up hot-reload using Server-Sent Events (SSE).
|
|
208
|
+
* Watches the frontend/ directory for .html, .css, .js file changes and notifies connected browsers.
|
|
209
|
+
* Also clears the Template cache on file changes so fresh content is served.
|
|
210
|
+
* Only active when DEBUG=true in .env.
|
|
211
|
+
* @private
|
|
212
|
+
*/
|
|
213
|
+
_setupHotReload() {
|
|
214
|
+
this.app.get("/__hot-reload", (req, res) => {
|
|
215
|
+
res.setHeader("x-no-compression", "true");
|
|
216
|
+
res.writeHead(200, {
|
|
217
|
+
"Content-Type": "text/event-stream",
|
|
218
|
+
"Cache-Control": "no-cache",
|
|
219
|
+
"Connection": "keep-alive",
|
|
220
|
+
"X-Accel-Buffering": "no",
|
|
221
|
+
});
|
|
222
|
+
res.write("data: connected\n\n");
|
|
223
|
+
if (typeof res.flush === "function") {
|
|
224
|
+
res.flush();
|
|
225
|
+
}
|
|
226
|
+
this._hotReloadClients.add(res);
|
|
227
|
+
req.on("close", () => {
|
|
228
|
+
this._hotReloadClients.delete(res);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const frontendPath = path.join(__dirname, "frontend");
|
|
233
|
+
let debounceTimer = null;
|
|
234
|
+
|
|
235
|
+
const notifyClients = () => {
|
|
236
|
+
this._hotReloadClients.forEach((client) => {
|
|
237
|
+
try {
|
|
238
|
+
client.write("data: reload\n\n");
|
|
239
|
+
if (typeof client.flush === "function") {
|
|
240
|
+
client.flush();
|
|
241
|
+
}
|
|
242
|
+
} catch (_) {
|
|
243
|
+
this._hotReloadClients.delete(client);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
fs.watch(frontendPath, { recursive: true }, (eventType, filename) => {
|
|
250
|
+
if (!filename) return;
|
|
251
|
+
if (/\.(html|css|js)$/i.test(filename)) {
|
|
252
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
253
|
+
debounceTimer = setTimeout(() => {
|
|
254
|
+
console.log(chalk.magenta(`[Hot Reload] ${filename} changed`));
|
|
255
|
+
Template.clearCache();
|
|
256
|
+
notifyClients();
|
|
257
|
+
}, 200);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
} catch (_) {
|
|
261
|
+
console.log(chalk.yellow("[Hot Reload] Could not watch frontend/ directory"));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Middleware that logs incoming HTTP requests to the console and to a log file.
|
|
267
|
+
* @private
|
|
268
|
+
* @param {boolean} [logger=false]
|
|
269
|
+
*/
|
|
270
|
+
_middlewareLogger(logger = false) {
|
|
271
|
+
this.app.use((req, res, next) => {
|
|
272
|
+
const method = req.method;
|
|
273
|
+
const url = req.url.replace(
|
|
274
|
+
/(password|token|authorization)=([^&]+)/gi,
|
|
275
|
+
"$1=***",
|
|
276
|
+
);
|
|
277
|
+
if (url.includes("chrome")) return;
|
|
278
|
+
const params =
|
|
279
|
+
Object.keys(req.params).length > 0
|
|
280
|
+
? ` ${JSON.stringify(req.params)}`
|
|
281
|
+
: "";
|
|
282
|
+
const ip = req.ip;
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
const time = `${now.split("T")[0]} ${now.split("T")[1].split(".")[0]}`;
|
|
285
|
+
let message = ` ${time} -${ip} -[${method}] ${url} ${params}`;
|
|
286
|
+
|
|
287
|
+
if (logger) this.loggerInstance.info(message);
|
|
288
|
+
|
|
289
|
+
if (props.debug) {
|
|
290
|
+
message = `${chalk.bold.green(time)} - ${chalk.bold.cyan(ip)} -[${chalk.bold.red(method)}] ${chalk.bold.blue(url)} ${chalk.bold.yellow(params)}`;
|
|
291
|
+
console.log(message);
|
|
292
|
+
}
|
|
293
|
+
next();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Global error handler for the application.
|
|
299
|
+
* @private
|
|
300
|
+
*/
|
|
301
|
+
_errorHandler() {
|
|
302
|
+
this.app.use((err, req, res, next) => {
|
|
303
|
+
const status = err.status || 500;
|
|
304
|
+
const message = err.message || "Internal Server Error";
|
|
305
|
+
|
|
306
|
+
this.loggerInstance.error(`[${status}] ${message} - ${err.stack}`);
|
|
307
|
+
|
|
308
|
+
if (props.debug) {
|
|
309
|
+
return res.status(status).json({
|
|
310
|
+
success: false,
|
|
311
|
+
error: true,
|
|
312
|
+
message: message,
|
|
313
|
+
stack: err.stack,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return res.status(status).json({
|
|
318
|
+
success: false,
|
|
319
|
+
error: true,
|
|
320
|
+
message: status === 500 ? "Internal Server Error" : message,
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Iterates through the stored routes and attaches them to the Express application instance.
|
|
327
|
+
* @private
|
|
328
|
+
*/
|
|
329
|
+
_dispatchRoutes() {
|
|
330
|
+
if (props.debug) {
|
|
331
|
+
const debug = new Debug();
|
|
332
|
+
const debugRouter = debug.getRouter();
|
|
333
|
+
this.app.use(debugRouter.path, debugRouter.router);
|
|
334
|
+
}
|
|
335
|
+
this.routes.forEach((route) => {
|
|
336
|
+
this.app.use(route.path, route.router);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Default 404 handler for unmatched routes.
|
|
342
|
+
* @private
|
|
343
|
+
*/
|
|
344
|
+
_notFoundDefault() {
|
|
345
|
+
this.app.use((req, res) => {
|
|
346
|
+
return res.status(404).json({ message: "Not Found" });
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Starts the HTTP server and begins listening for incoming connections.
|
|
352
|
+
*/
|
|
353
|
+
|
|
354
|
+
run() {
|
|
355
|
+
this._ready
|
|
356
|
+
.then(() => {
|
|
357
|
+
this.app.listen(this.port, () => {
|
|
358
|
+
console.log(
|
|
359
|
+
chalk.bold.blue("Blue Bird Server Online\n") +
|
|
360
|
+
chalk.bold.cyan("App URL: ") +
|
|
361
|
+
chalk.green(`${this.appUrl}`) +
|
|
362
|
+
"\n" +
|
|
363
|
+
chalk.bold.cyan("Internal: ") +
|
|
364
|
+
chalk.green(`${this.host}:${this.port}`) +
|
|
365
|
+
"\n" +
|
|
366
|
+
(props.debug ? chalk.bold.magenta("Hot Reload: enabled\n") : "") +
|
|
367
|
+
chalk.gray("────────────────────────────────"),
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
})
|
|
371
|
+
.catch((err) => {
|
|
372
|
+
console.error(
|
|
373
|
+
chalk.bold.red("Failed to start Blue Bird:"),
|
|
374
|
+
err.message,
|
|
375
|
+
);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Returns a pre-configured Helmet middleware for use on specific routers.
|
|
382
|
+
* @param {Object} [options={}] - Helmet options to override defaults.
|
|
383
|
+
* @returns {Function} Helmet middleware function.
|
|
384
|
+
* @example
|
|
385
|
+
* const router = new Router("/web");
|
|
386
|
+
* router.use(App.helmet({ contentSecurityPolicy: false }));
|
|
387
|
+
*/
|
|
388
|
+
static helmet(options = {}) {
|
|
389
|
+
const defaultOptions = {
|
|
390
|
+
contentSecurityPolicy: props.debug ? false : undefined,
|
|
391
|
+
hidePoweredBy: false,
|
|
392
|
+
};
|
|
393
|
+
return helmet({ ...defaultOptions, ...options });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export default App;
|
package/core/auth.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import Config from "./config.js";
|
|
4
|
+
|
|
5
|
+
const propsConfig = Config.props();
|
|
6
|
+
const jwtSecret = propsConfig.jwtSecret;
|
|
7
|
+
const production = !propsConfig.debug;
|
|
8
|
+
/**
|
|
9
|
+
* Auth class to handle JWT generation, verification and protection with AES-256-GCM encryption.
|
|
10
|
+
*/
|
|
11
|
+
class Auth {
|
|
12
|
+
/**
|
|
13
|
+
* Encrypts a payload using AES-256-GCM.
|
|
14
|
+
* @param {Object} payload - The data to encrypt.
|
|
15
|
+
* @param {string} secret - The secret key for encryption.
|
|
16
|
+
* @returns {string} The encrypted string in format iv:tag:encrypted.
|
|
17
|
+
*/
|
|
18
|
+
static encrypt(payload, secret) {
|
|
19
|
+
const iv = crypto.randomBytes(12);
|
|
20
|
+
const key = crypto.createHash("sha256").update(secret).digest();
|
|
21
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
22
|
+
let encrypted = cipher.update(JSON.stringify(payload), "utf8", "hex");
|
|
23
|
+
encrypted += cipher.final("hex");
|
|
24
|
+
const tag = cipher.getAuthTag().toString("hex");
|
|
25
|
+
return `${iv.toString("hex")}:${tag}:${encrypted}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decrypts a payload using AES-256-GCM.
|
|
30
|
+
* @param {string} data - The encrypted string in format iv:tag:encrypted.
|
|
31
|
+
* @param {string} secret - The secret key for decryption.
|
|
32
|
+
* @returns {Object|null} The decrypted object or null if failed.
|
|
33
|
+
*/
|
|
34
|
+
static decrypt(data, secret) {
|
|
35
|
+
try {
|
|
36
|
+
const [ivHex, tagHex, encryptedHex] = data.split(":");
|
|
37
|
+
if (!ivHex || !tagHex || !encryptedHex) return null;
|
|
38
|
+
|
|
39
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
40
|
+
const tag = Buffer.from(tagHex, "hex");
|
|
41
|
+
const key = crypto.createHash("sha256").update(secret).digest();
|
|
42
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
43
|
+
decipher.setAuthTag(tag);
|
|
44
|
+
|
|
45
|
+
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
|
46
|
+
decrypted += decipher.final("utf8");
|
|
47
|
+
return JSON.parse(decrypted);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generates an encrypted JWT token.
|
|
55
|
+
* @param {Object} payload - The data to store in the token.
|
|
56
|
+
* @param {string} [secret=process.env.JWT_SECRET] - The secret key .
|
|
57
|
+
* @param {string} [expiresIn="24h"] - Expiration time.
|
|
58
|
+
* @returns {string} The generated token.
|
|
59
|
+
*/
|
|
60
|
+
static generateToken(
|
|
61
|
+
payload,
|
|
62
|
+
secret = jwtSecret,
|
|
63
|
+
expiresIn = "24h"
|
|
64
|
+
) {
|
|
65
|
+
if (!secret)
|
|
66
|
+
throw new Error("FATAL: JWT_SECRET environment variable is not defined.");
|
|
67
|
+
const encrypted = this.encrypt(payload, secret);
|
|
68
|
+
return jwt.sign({ data: encrypted }, secret, { expiresIn });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verifies and decrypts a JWT token.
|
|
73
|
+
* @param {string} token - The token to verify.
|
|
74
|
+
* @param {string} [secret=process.env.JWT_SECRET] - The secret key.
|
|
75
|
+
* @returns {Object|null} The decoded and decrypted payload or null if invalid.
|
|
76
|
+
*/
|
|
77
|
+
static verifyToken(token, secret = jwtSecret) {
|
|
78
|
+
if (!secret)
|
|
79
|
+
throw new Error("FATAL: JWT_SECRET environment variable is not defined.");
|
|
80
|
+
try {
|
|
81
|
+
const decoded = jwt.verify(token, secret);
|
|
82
|
+
if (!decoded || !decoded.data) return null;
|
|
83
|
+
return this.decrypt(decoded.data, secret);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Middleware to protect routes. Checks for token in Cookies or Authorization header.
|
|
91
|
+
* @param {Object} [options={}] - Options for protection.
|
|
92
|
+
* @param {string} [options.redirect=null] - URL to redirect if not authenticated.
|
|
93
|
+
* @param {string} [options.key="user"] - Key to store the decoded token in the request.
|
|
94
|
+
* @param {string} [options.cookieKey="auth"] - The cookie key to look for the token.
|
|
95
|
+
* @returns {Function} Express middleware.
|
|
96
|
+
* @example
|
|
97
|
+
* router.get("/profile", Auth.protect(), (req, res) => { ... });
|
|
98
|
+
* // Or with custom cookie key:
|
|
99
|
+
* router.get("/admin", Auth.protect({ cookieKey: "admin_session" }), (req, res) => { ... });
|
|
100
|
+
*/
|
|
101
|
+
static protect(options = {}) {
|
|
102
|
+
const { redirect = null, key = "user", cookieKey = "auth" } = options;
|
|
103
|
+
|
|
104
|
+
return (req, res, next) => {
|
|
105
|
+
const token =
|
|
106
|
+
req.cookies?.[cookieKey] || req.headers.authorization?.split(" ")[1];
|
|
107
|
+
|
|
108
|
+
const isContentTypeJson =
|
|
109
|
+
req.headers["content-type"] === "application/json";
|
|
110
|
+
|
|
111
|
+
if (!token) {
|
|
112
|
+
if (redirect && !isContentTypeJson) return res.redirect(redirect);
|
|
113
|
+
return isContentTypeJson
|
|
114
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
115
|
+
: res.status(401).send();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const decoded = this.verifyToken(token);
|
|
119
|
+
if (!decoded) {
|
|
120
|
+
if (redirect && !isContentTypeJson) return res.redirect(redirect);
|
|
121
|
+
return isContentTypeJson
|
|
122
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
123
|
+
: res.status(401).send();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
req[key || "user"] = decoded;
|
|
127
|
+
next();
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Logs in a user by setting an authentication cookie.
|
|
133
|
+
* @param {import('express').Response} res - The response object.
|
|
134
|
+
* @param {Object} data - The data to store in the token.
|
|
135
|
+
* @param {string} [key="auth"] - The key for the cookie.
|
|
136
|
+
* @param {Object} [options={}] - Options for the cookie and token.
|
|
137
|
+
* @param {string} [options.expiresIn="24h"] - Token expiration (e.g., "1h", "7d").
|
|
138
|
+
* @param {import('express').CookieOptions} [options.cookie] - Express cookie options.
|
|
139
|
+
* @returns {Promise<string>} The generated token.
|
|
140
|
+
* @example
|
|
141
|
+
* await Auth.login(res, { id: 1, name: "Admin" });
|
|
142
|
+
*/
|
|
143
|
+
static async login(res, data, key = "auth", options = {}) {
|
|
144
|
+
const { expiresIn = "24h", cookie = {} } = options;
|
|
145
|
+
|
|
146
|
+
const token = this.generateToken(data, jwtSecret, expiresIn);
|
|
147
|
+
|
|
148
|
+
const defaultCookieOptions = {
|
|
149
|
+
maxAge: 24 * 60 * 60 * 1000,
|
|
150
|
+
httpOnly: true,
|
|
151
|
+
secure: production,
|
|
152
|
+
sameSite: "strict",
|
|
153
|
+
path: "/",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const finalCookieOptions = { ...defaultCookieOptions, ...cookie };
|
|
157
|
+
|
|
158
|
+
res.cookie(key, token, finalCookieOptions);
|
|
159
|
+
return token;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Logs out a user by clearing the authentication cookie.
|
|
164
|
+
* @param {import('express').Response} res - The response object.
|
|
165
|
+
* @param {string} [key="auth"] - The key for the cookie.
|
|
166
|
+
* @param {import('express').CookieOptions} [options={}] - Options for clearing the cookie.
|
|
167
|
+
* @returns {Promise<boolean>} True if the cookie was cleared successfully.
|
|
168
|
+
* @example
|
|
169
|
+
* await Auth.logout(res);
|
|
170
|
+
*/
|
|
171
|
+
static async logout(res, key = "auth", options = {}) {
|
|
172
|
+
const defaultOptions = {
|
|
173
|
+
path: "/",
|
|
174
|
+
};
|
|
175
|
+
res.clearCookie(key, { ...defaultOptions, ...options });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default Auth;
|