@seip/blue-bird 0.4.6 → 0.4.8
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/AGENTS.md +40 -2
- package/backend/index.js +19 -0
- package/backend/routes/api.js +44 -0
- package/backend/routes/frontend.js +11 -5
- package/core/app.js +12 -10
- package/core/auth.js +77 -11
- package/core/cache.js +32 -29
- package/core/config.js +5 -1
- package/core/seo.js +1 -1
- package/core/template.js +6 -0
- package/core/upload.js +2 -1
- package/frontend/index.html +6 -1
- package/frontend/landing.html +1 -0
- package/frontend/resources/js/App.jsx +1 -3
- package/frontend/resources/js/blue-bird/contexts/SPAContext.jsx +5 -6
- package/frontend/resources/js/pages/About.jsx +4 -0
- package/frontend/resources/js/pages/Home.jsx +6 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -123,19 +123,57 @@ routerApi.post("/users", validateUser.middleware(), (req, res) => {
|
|
|
123
123
|
|
|
124
124
|
## 4. Authentication (Auth)
|
|
125
125
|
|
|
126
|
-
The system includes built-in JWT handling. The framework
|
|
126
|
+
The system includes built-in JWT handling with AES-256-GCM encryption. The framework handles tokens via Cookies or the `Authorization` header.
|
|
127
|
+
|
|
128
|
+
### Protecting Routes
|
|
129
|
+
Use `Auth.protect()` as a middleware to secure routes.
|
|
127
130
|
|
|
128
131
|
```javascript
|
|
129
132
|
import Auth from "@seip/blue-bird/core/auth.js";
|
|
130
133
|
|
|
131
134
|
// Protect an API route (returns 401 if failed):
|
|
132
135
|
router.get("/profile", Auth.protect(), (req, res) => {
|
|
133
|
-
// The user payload is attached to req.user
|
|
136
|
+
// The decrypted user payload is attached to req.user
|
|
134
137
|
res.json({ user: req.user });
|
|
135
138
|
});
|
|
136
139
|
|
|
137
140
|
// Protect a React route (redirects to login):
|
|
138
141
|
router.get("/dashboard", Auth.protect({ redirect: "/login" }), (req, res) => { ... });
|
|
142
|
+
|
|
143
|
+
// Custom cookie key and storage key:
|
|
144
|
+
router.get("/admin", Auth.protect({
|
|
145
|
+
cookieKey: "admin_session",
|
|
146
|
+
key: "admin"
|
|
147
|
+
}), (req, res) => {
|
|
148
|
+
res.json({ admin: req.admin });
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Login and Logout
|
|
153
|
+
The `Auth` class provides helpers to handle session management via cookies.
|
|
154
|
+
|
|
155
|
+
```javascript
|
|
156
|
+
// Login a user
|
|
157
|
+
router.post("/login", async (req, res) => {
|
|
158
|
+
const user = { id: 1, name: "John" };
|
|
159
|
+
|
|
160
|
+
// Generates JWT and sets "auth" cookie (24h default)
|
|
161
|
+
await Auth.login(res, user);
|
|
162
|
+
|
|
163
|
+
res.json({ message: "Logged in" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Logout a user
|
|
167
|
+
router.post("/logout", async (req, res) => {
|
|
168
|
+
await Auth.logout(res);
|
|
169
|
+
res.json({ message: "Logged out" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Customizing Login (key, expiration, cookie options)
|
|
173
|
+
await Auth.login(res, user, "my_session", {
|
|
174
|
+
expiresIn: "7d",
|
|
175
|
+
cookie: { httpOnly: true, secure: true }
|
|
176
|
+
});
|
|
139
177
|
```
|
|
140
178
|
|
|
141
179
|
## 5. Performance Coaching (Cache)
|
package/backend/index.js
CHANGED
|
@@ -2,12 +2,31 @@ import App from "@seip/blue-bird/core/app.js";
|
|
|
2
2
|
import routerApiExample from "./routes/api.js";
|
|
3
3
|
import routerFrontendExample from "./routes/frontend.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Main entry point for the Blue Bird application.
|
|
7
|
+
* Initializes the App instance with routes, configuration, and starts the server.
|
|
8
|
+
*/
|
|
5
9
|
const app = new App({
|
|
10
|
+
/**
|
|
11
|
+
* Array of router instances to be registered in the application.
|
|
12
|
+
* Each router can have its own base path.
|
|
13
|
+
*/
|
|
6
14
|
routes: [routerApiExample, routerFrontendExample],
|
|
15
|
+
|
|
16
|
+
/** CORS configuration. If empty, uses default settings. */
|
|
7
17
|
cors: [],
|
|
18
|
+
|
|
19
|
+
/** Global middlewares to be applied before routes. */
|
|
8
20
|
middlewares: [],
|
|
21
|
+
|
|
22
|
+
/** Base host URL for the application. */
|
|
9
23
|
host: "http://localhost",
|
|
24
|
+
|
|
25
|
+
/** Server port, defaults to environment variable or fallback in config. */
|
|
10
26
|
port: process.env.PORT,
|
|
11
27
|
});
|
|
12
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Starts the application server.
|
|
31
|
+
*/
|
|
13
32
|
app.run();
|
package/backend/routes/api.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import Router from "@seip/blue-bird/core/router.js";
|
|
2
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"
|
|
3
5
|
|
|
4
6
|
const routerApiExample = new Router("/api");
|
|
5
7
|
|
|
8
|
+
/* simple test route */
|
|
6
9
|
routerApiExample.get("/users", (req, res) => {
|
|
7
10
|
const users = [
|
|
8
11
|
{
|
|
@@ -16,7 +19,10 @@ routerApiExample.get("/users", (req, res) => {
|
|
|
16
19
|
];
|
|
17
20
|
res.json(users);
|
|
18
21
|
});
|
|
22
|
+
/* End simple test route */
|
|
19
23
|
|
|
24
|
+
|
|
25
|
+
/* Validation example */
|
|
20
26
|
const loginSchema = {
|
|
21
27
|
email: { required: true, email: true },
|
|
22
28
|
password: { required: true, min: 6 },
|
|
@@ -27,5 +33,43 @@ const loginValidator = new Validator(loginSchema);
|
|
|
27
33
|
routerApiExample.post("/login", loginValidator.middleware(), (req, res) => {
|
|
28
34
|
res.json({ message: "Login successful", body: req.body });
|
|
29
35
|
});
|
|
36
|
+
/* End Validation example */
|
|
37
|
+
|
|
38
|
+
/* Cache example */
|
|
39
|
+
routerApiExample.get("/cache", Cache.middleware(), async (req, res) => {
|
|
40
|
+
//in debug =false , testing /api/cache should take 2 seconds , and after that should take 0 seconds
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
42
|
+
res.json({ message: "Cache successful" });
|
|
43
|
+
})
|
|
44
|
+
/* End Cache example */
|
|
45
|
+
|
|
46
|
+
/* Auth example */
|
|
47
|
+
routerApiExample.get("/auth_generate", async (req, res) => {
|
|
48
|
+
const token = await Auth.login(res, { id: 1, name: "John Doe" })
|
|
49
|
+
/*Or use
|
|
50
|
+
const token = Auth.generateToken({ id: user.id });//to generate token
|
|
51
|
+
res.cookie("auth", token,{
|
|
52
|
+
maxAge: 24 * 60 * 60 * 1000,
|
|
53
|
+
httpOnly: true,
|
|
54
|
+
secure: production,
|
|
55
|
+
sameSite: "strict",
|
|
56
|
+
path: "/",
|
|
57
|
+
});
|
|
58
|
+
*/
|
|
59
|
+
res.json({ message: "Auth successful", token });
|
|
60
|
+
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
routerApiExample.get("/auth_logout", async (req, res) => {
|
|
64
|
+
await Auth.logout(res)
|
|
65
|
+
res.json({ message: "Auth successful" });
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
routerApiExample.get("/auth_verify", Auth.protect(), (req, res) => {
|
|
69
|
+
// req.user has inject with the jwt token payload and decoded by the Auth.protect() middleware
|
|
70
|
+
const userInfo = req.user
|
|
71
|
+
res.json({ message: "Auth successful", user: userInfo });
|
|
72
|
+
})
|
|
73
|
+
/* End Auth example */
|
|
30
74
|
|
|
31
75
|
export default routerApiExample;
|
|
@@ -6,36 +6,42 @@ import seoData from "./seo.js";
|
|
|
6
6
|
const routerFrontendExample = new Router();
|
|
7
7
|
routerFrontendExample.use(App.helmet()); // Helmet for frontend router
|
|
8
8
|
|
|
9
|
+
/* Render HTML frontend/landing.html */
|
|
9
10
|
routerFrontendExample.get("/landing", (req, res) => {
|
|
10
11
|
return Template.renderHtml(res, "landing", {
|
|
11
|
-
metaTags:{
|
|
12
|
-
titleMeta:"Landing Example",
|
|
12
|
+
metaTags: {
|
|
13
|
+
titleMeta: "Landing Example",
|
|
13
14
|
descriptionMeta: "Description meta",
|
|
14
15
|
keywordsMeta: "keywordsMeta"
|
|
15
16
|
}
|
|
16
17
|
});
|
|
17
18
|
});
|
|
19
|
+
/* End Render HTML frontend/landing.html */
|
|
18
20
|
|
|
21
|
+
/* SEO example */
|
|
19
22
|
routerFrontendExample.seo(
|
|
20
23
|
[
|
|
21
24
|
{
|
|
22
25
|
path: "/",
|
|
23
26
|
component: "Home",
|
|
24
|
-
seoKey: "home"
|
|
25
|
-
props: { id: 1, name: "Name" }
|
|
27
|
+
seoKey: "home",//key in seo.js data
|
|
28
|
+
props: { id: 1, name: "Name" },// Props pass to react component or
|
|
26
29
|
},
|
|
27
30
|
{
|
|
28
31
|
path: "/about",
|
|
29
32
|
component: "About",
|
|
30
|
-
seoKey: "about"
|
|
33
|
+
seoKey: "about",//key in seo.js data
|
|
31
34
|
props: { id: 2, name: "Name 2" },
|
|
32
35
|
},
|
|
33
36
|
],
|
|
34
37
|
{ languages: ["en", "es"], defaultLanguage: "en", seoData },
|
|
35
38
|
);
|
|
39
|
+
/* End SEO example */
|
|
36
40
|
|
|
41
|
+
/* Render React example for '*' route (catch all routes) */
|
|
37
42
|
routerFrontendExample.get("*", (req, res) => {
|
|
38
43
|
return Template.renderReact(res, "App");
|
|
39
44
|
});
|
|
45
|
+
/* End Render React example */
|
|
40
46
|
|
|
41
47
|
export default routerFrontendExample;
|
package/core/app.js
CHANGED
|
@@ -160,7 +160,7 @@ class App {
|
|
|
160
160
|
this.app.use(middleware);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
if (this.logger) this._middlewareLogger();
|
|
163
|
+
if (this.logger || props.debug) this._middlewareLogger(this.logger);
|
|
164
164
|
|
|
165
165
|
this.app.use((req, res, next) => {
|
|
166
166
|
res.setHeader("X-Powered-By", "Blue Bird");
|
|
@@ -202,13 +202,14 @@ class App {
|
|
|
202
202
|
* Middleware that logs incoming HTTP requests to the console and to a log file.
|
|
203
203
|
* @private
|
|
204
204
|
*/
|
|
205
|
-
_middlewareLogger() {
|
|
205
|
+
_middlewareLogger(logger = false) {
|
|
206
206
|
this.app.use((req, res, next) => {
|
|
207
207
|
const method = req.method;
|
|
208
208
|
const url = req.url.replace(
|
|
209
209
|
/(password|token|authorization)=([^&]+)/gi,
|
|
210
210
|
"$1=***",
|
|
211
211
|
);
|
|
212
|
+
if (url.includes("chrome")) return;
|
|
212
213
|
const params =
|
|
213
214
|
Object.keys(req.params).length > 0
|
|
214
215
|
? ` ${JSON.stringify(req.params)}`
|
|
@@ -218,7 +219,8 @@ class App {
|
|
|
218
219
|
const time = `${now.split("T")[0]} ${now.split("T")[1].split(".")[0]}`;
|
|
219
220
|
let message = ` ${time} -${ip} -[${method}] ${url} ${params}`;
|
|
220
221
|
|
|
221
|
-
this.loggerInstance.info(message);
|
|
222
|
+
if (logger) this.loggerInstance.info(message);
|
|
223
|
+
|
|
222
224
|
if (props.debug) {
|
|
223
225
|
message = `${chalk.bold.green(time)} - ${chalk.bold.cyan(ip)} -[${chalk.bold.red(method)}] ${chalk.bold.blue(url)} ${chalk.bold.yellow(params)}`;
|
|
224
226
|
console.log(message);
|
|
@@ -290,13 +292,13 @@ class App {
|
|
|
290
292
|
this.app.listen(this.port, () => {
|
|
291
293
|
console.log(
|
|
292
294
|
chalk.bold.blue("Blue Bird Server Online\n") +
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
295
|
+
chalk.bold.cyan("App URL: ") +
|
|
296
|
+
chalk.green(`${this.appUrl}`) +
|
|
297
|
+
"\n" +
|
|
298
|
+
chalk.bold.cyan("Internal: ") +
|
|
299
|
+
chalk.green(`${this.host}:${this.port}`) +
|
|
300
|
+
"\n" +
|
|
301
|
+
chalk.gray("────────────────────────────────"),
|
|
300
302
|
);
|
|
301
303
|
});
|
|
302
304
|
})
|
package/core/auth.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import jwt from "jsonwebtoken";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import Config from "./config.js";
|
|
3
4
|
|
|
5
|
+
const propsConfig = Config.props();
|
|
6
|
+
const jwtSecret = propsConfig.jwtSecret;
|
|
7
|
+
const production = propsConfig.debug;
|
|
4
8
|
/**
|
|
5
9
|
* Auth class to handle JWT generation, verification and protection with AES-256-GCM encryption.
|
|
6
10
|
*/
|
|
@@ -49,13 +53,13 @@ class Auth {
|
|
|
49
53
|
/**
|
|
50
54
|
* Generates an encrypted JWT token.
|
|
51
55
|
* @param {Object} payload - The data to store in the token.
|
|
52
|
-
* @param {string} [secret=process.env.JWT_SECRET] - The secret key.
|
|
56
|
+
* @param {string} [secret=process.env.JWT_SECRET] - The secret key .
|
|
53
57
|
* @param {string} [expiresIn="24h"] - Expiration time.
|
|
54
58
|
* @returns {string} The generated token.
|
|
55
59
|
*/
|
|
56
60
|
static generateToken(
|
|
57
61
|
payload,
|
|
58
|
-
secret =
|
|
62
|
+
secret = jwtSecret,
|
|
59
63
|
expiresIn = "24h"
|
|
60
64
|
) {
|
|
61
65
|
if (!secret)
|
|
@@ -70,7 +74,7 @@ class Auth {
|
|
|
70
74
|
* @param {string} [secret=process.env.JWT_SECRET] - The secret key.
|
|
71
75
|
* @returns {Object|null} The decoded and decrypted payload or null if invalid.
|
|
72
76
|
*/
|
|
73
|
-
static verifyToken(token, secret =
|
|
77
|
+
static verifyToken(token, secret = jwtSecret) {
|
|
74
78
|
if (!secret)
|
|
75
79
|
throw new Error("FATAL: JWT_SECRET environment variable is not defined.");
|
|
76
80
|
try {
|
|
@@ -84,31 +88,93 @@ class Auth {
|
|
|
84
88
|
|
|
85
89
|
/**
|
|
86
90
|
* Middleware to protect routes. Checks for token in Cookies or Authorization header.
|
|
87
|
-
* @param {Object} options - Options for protection.
|
|
91
|
+
* @param {Object} [options={}] - Options for protection.
|
|
88
92
|
* @param {string} [options.redirect=null] - URL to redirect if not authenticated.
|
|
89
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.
|
|
90
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) => { ... });
|
|
91
100
|
*/
|
|
92
|
-
static protect(options = {
|
|
101
|
+
static protect(options = {}) {
|
|
102
|
+
const { redirect = null, key = "user", cookieKey = "auth" } = options;
|
|
103
|
+
|
|
93
104
|
return (req, res, next) => {
|
|
94
105
|
const token =
|
|
95
|
-
req.cookies?.
|
|
106
|
+
req.cookies?.[cookieKey] || req.headers.authorization?.split(" ")[1];
|
|
107
|
+
|
|
108
|
+
const isContentTypeJson =
|
|
109
|
+
req.headers["content-type"] === "application/json";
|
|
96
110
|
|
|
97
111
|
if (!token) {
|
|
98
|
-
if (
|
|
99
|
-
return
|
|
112
|
+
if (redirect && !isContentTypeJson) return res.redirect(redirect);
|
|
113
|
+
return isContentTypeJson
|
|
114
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
115
|
+
: res.status(401).send();
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
const decoded = this.verifyToken(token);
|
|
103
119
|
if (!decoded) {
|
|
104
|
-
if (
|
|
105
|
-
return
|
|
120
|
+
if (redirect && !isContentTypeJson) return res.redirect(redirect);
|
|
121
|
+
return isContentTypeJson
|
|
122
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
123
|
+
: res.status(401).send();
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
req[
|
|
126
|
+
req[key || "user"] = decoded;
|
|
109
127
|
next();
|
|
110
128
|
};
|
|
111
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
|
+
}
|
|
112
178
|
}
|
|
113
179
|
|
|
114
180
|
export default Auth;
|
package/core/cache.js
CHANGED
|
@@ -9,37 +9,40 @@ setInterval(() => {
|
|
|
9
9
|
}
|
|
10
10
|
}, 300000).unref();
|
|
11
11
|
/**
|
|
12
|
-
* Cache
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
Cache.middleware(120),
|
|
16
|
-
controller.stats
|
|
17
|
-
);
|
|
18
|
-
* */
|
|
12
|
+
* Simple in-memory Cache class to provide middleware for Express routes.
|
|
13
|
+
* Caches JSON responses based on the request URL.
|
|
14
|
+
*/
|
|
19
15
|
class Cache {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Middleware to cache responses.
|
|
18
|
+
* @param {number} [seconds=60] - Number of seconds to cache the response.
|
|
19
|
+
* @returns {Function} Express middleware function.
|
|
20
|
+
* @example
|
|
21
|
+
* router.get("/stats", Cache.middleware(120), (req, res) => {
|
|
22
|
+
* res.json({ ok: true });
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
static middleware(seconds = 60) {
|
|
26
|
+
return (req, res, next) => {
|
|
27
|
+
const key = req.originalUrl;
|
|
28
|
+
|
|
29
|
+
if (CACHE[key] && CACHE[key].expiry > Date.now()) {
|
|
30
|
+
return res.json(CACHE[key].data);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const originalJson = res.json.bind(res);
|
|
34
|
+
|
|
35
|
+
res.json = (body) => {
|
|
36
|
+
CACHE[key] = {
|
|
37
|
+
data: body,
|
|
38
|
+
expiry: Date.now() + seconds * 1000,
|
|
41
39
|
};
|
|
42
|
-
|
|
40
|
+
return originalJson(body);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
next();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export default Cache;
|
package/core/config.js
CHANGED
|
@@ -17,7 +17,10 @@ class Config {
|
|
|
17
17
|
/**
|
|
18
18
|
* Retrieves application properties from environment variables or default values.
|
|
19
19
|
* Results are cached after first call for performance.
|
|
20
|
-
* @returns {Object} The configuration properties object.
|
|
20
|
+
* @returns {{debug: boolean, descriptionMeta: string, keywordsMeta: string, titleMeta: string, authorMeta: string, description: string, title: string, version: string, langMeta: string, host: string, appUrl: string, port: number, static: {path: string, options: Object}}} The configuration properties object.
|
|
21
|
+
* @example
|
|
22
|
+
* const props = Config.props();
|
|
23
|
+
* console.log(props);
|
|
21
24
|
*/
|
|
22
25
|
static props() {
|
|
23
26
|
if (_cachedProps) return _cachedProps;
|
|
@@ -37,6 +40,7 @@ class Config {
|
|
|
37
40
|
host: process.env.HOST || "http://localhost",
|
|
38
41
|
appUrl: process.env.APP_URL || process.env.HOST || "http://localhost",
|
|
39
42
|
port: Number.isNaN(portRaw) ? 3000 : portRaw,
|
|
43
|
+
jwtSecret: process.env.JWT_SECRET,
|
|
40
44
|
static: {
|
|
41
45
|
path: process.env.STATIC_PATH || "frontend/public",
|
|
42
46
|
options: {},
|
package/core/seo.js
CHANGED
|
@@ -20,7 +20,7 @@ class SEO {
|
|
|
20
20
|
*/
|
|
21
21
|
static generateSitemap(routesConfig, options = {}) {
|
|
22
22
|
const { languages = [], defaultLanguage = "en" } = options;
|
|
23
|
-
const host = (props.appUrl ||
|
|
23
|
+
const host = (props.appUrl || `${props.host}:${props.port}`).replace(/\/$/, "");
|
|
24
24
|
const baseUrl = `${host}`;
|
|
25
25
|
const date = new Date().toISOString().split("T")[0];
|
|
26
26
|
|
package/core/template.js
CHANGED
|
@@ -184,6 +184,8 @@ class Template {
|
|
|
184
184
|
: "";
|
|
185
185
|
const skeletonHtml = skeleton ? this.skeletonHtml() : "";
|
|
186
186
|
|
|
187
|
+
const canonicalUrl = metaTags.canonicalUrl || props.appUrl || "";
|
|
188
|
+
|
|
187
189
|
const ogTags = `
|
|
188
190
|
<meta property="og:title" content="${title}" />
|
|
189
191
|
<meta property="og:description" content="${description}" />
|
|
@@ -210,6 +212,7 @@ class Template {
|
|
|
210
212
|
.replace(/__VITE_ASSETS__/g, this.vite_assets())
|
|
211
213
|
.replace(/__SCRIPTS_BODY__/g, scriptsBodyTags)
|
|
212
214
|
.replace(/__STYLES_SKELETON__/g, stylesSkeleton)
|
|
215
|
+
.replace(/__CANONICAL_URL__/g, canonicalUrl)
|
|
213
216
|
.replace(/__SKELETON__/g, skeletonHtml);
|
|
214
217
|
|
|
215
218
|
html = this.minifyHtml(html);
|
|
@@ -338,6 +341,8 @@ class Template {
|
|
|
338
341
|
${ogImage ? `<meta name="twitter:image" content="${ogImage}" />` : ""}
|
|
339
342
|
`;
|
|
340
343
|
|
|
344
|
+
const canonicalUrl = metaTags.canonicalUrl || props.appUrl || "";
|
|
345
|
+
|
|
341
346
|
html = html
|
|
342
347
|
.replace(/__LANG__/g, this.escapeHtml(langHtml))
|
|
343
348
|
.replace(/__TITLE__/g, title)
|
|
@@ -347,6 +352,7 @@ class Template {
|
|
|
347
352
|
.replace(/__HEAD_OPTIONS__/g, headOptions + ogTags)
|
|
348
353
|
.replace(/__CLASS_BODY__/g, classBody)
|
|
349
354
|
.replace(/__VITE_ASSETS__/g, withAssets ? this.vite_assets() : "")
|
|
355
|
+
.replace(/__CANONICAL_URL__/g, canonicalUrl)
|
|
350
356
|
.replace(/__STYLES_SKELETON__/g, "");
|
|
351
357
|
|
|
352
358
|
if (html.includes("__LINK_STYLES__")) {
|
package/core/upload.js
CHANGED
|
@@ -69,7 +69,8 @@ class Upload {
|
|
|
69
69
|
* const url = Upload.url("file.jpg", "uploads");
|
|
70
70
|
*/
|
|
71
71
|
static url(filename, folder = "uploads") {
|
|
72
|
-
|
|
72
|
+
const appUrl = props.appUrl ?? `${props.host}:${props.port}`;
|
|
73
|
+
return `${appUrl}/${folder}/${filename}`;
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
package/frontend/index.html
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="__LANG__">
|
|
3
|
+
|
|
3
4
|
<head>
|
|
4
5
|
<meta charset="UTF-8">
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
7
|
<title>__TITLE__</title>
|
|
8
|
+
<link rel="canonical" href="__CANONICAL_URL__" />
|
|
7
9
|
<link rel="icon" href="/favicon.ico" />
|
|
8
10
|
<meta name="description" content="__DESCRIPTION__" />
|
|
9
11
|
<meta name="keywords" content="__KEYWORDS__" />
|
|
10
12
|
<meta name="author" content="__AUTHOR__" />
|
|
11
13
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
12
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
13
|
-
<link
|
|
15
|
+
<link
|
|
16
|
+
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap"
|
|
17
|
+
rel="stylesheet">
|
|
14
18
|
__HEAD_OPTIONS__
|
|
15
19
|
__LINK_STYLES__
|
|
16
20
|
__SCRIPTS_HEAD__
|
|
17
21
|
__VITE_ASSETS__
|
|
18
22
|
__STYLES_SKELETON__
|
|
19
23
|
</head>
|
|
24
|
+
|
|
20
25
|
<body class="__CLASS_BODY__">
|
|
21
26
|
<div id="root" data-react-component="__COMPONENT__" data-props='__PROPS__'>
|
|
22
27
|
__SKELETON__
|
package/frontend/landing.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
7
|
<title>__TITLE__</title>
|
|
8
|
+
<link rel="canonical" href="__CANONICAL_URL__" />
|
|
8
9
|
<meta name="description" content="__DESCRIPTION__">
|
|
9
10
|
<meta name="keywords" content="__KEYWORDS__">
|
|
10
11
|
<meta name="author" content="__AUTHOR__">
|
|
@@ -30,12 +30,10 @@ function generateRoutes(routes, languages = []) {
|
|
|
30
30
|
const allRoutes = [];
|
|
31
31
|
|
|
32
32
|
routes.forEach(({ path: routePath, element }) => {
|
|
33
|
-
// Base path
|
|
34
33
|
allRoutes.push(
|
|
35
34
|
<Route key={routePath} path={routePath} element={element} />
|
|
36
35
|
);
|
|
37
36
|
|
|
38
|
-
// Language-prefixed paths (only if multiple languages are used)
|
|
39
37
|
if (languages && languages.length > 0) {
|
|
40
38
|
languages.forEach((lang) => {
|
|
41
39
|
const langPath = `/${lang}${routePath === "/" ? "" : routePath}`;
|
|
@@ -56,7 +54,7 @@ export default function App(_props) {
|
|
|
56
54
|
<ThemeProvider>
|
|
57
55
|
<LanguageProvider initialLang={lang}>
|
|
58
56
|
<Router>
|
|
59
|
-
<SPAProvider languages={LANGUAGES} defaultLanguage={DEFAULT_LANGUAGE}>
|
|
57
|
+
<SPAProvider languages={LANGUAGES} defaultLanguage={DEFAULT_LANGUAGE} {..._props} >
|
|
60
58
|
<Suspense fallback={<Skeleton />}>
|
|
61
59
|
<Routes>
|
|
62
60
|
{generateRoutes(ROUTES, LANGUAGES)}
|
|
@@ -28,7 +28,7 @@ const SPAContext = createContext({
|
|
|
28
28
|
* @param {Array<string>} [props.languages=[]] - Supported language codes.
|
|
29
29
|
* @param {string} [props.defaultLanguage="en"] - Default language.
|
|
30
30
|
*/
|
|
31
|
-
export function SPAProvider({
|
|
31
|
+
export function SPAProvider({ props,
|
|
32
32
|
children,
|
|
33
33
|
languages = [],
|
|
34
34
|
defaultLanguage = "en",
|
|
@@ -36,7 +36,7 @@ export function SPAProvider({
|
|
|
36
36
|
const location = useLocation();
|
|
37
37
|
const navigate = useNavigate();
|
|
38
38
|
const { lang, setLang } = useLanguage();
|
|
39
|
-
const [pageProps, setPageProps] = useState(
|
|
39
|
+
const [pageProps, setPageProps] = useState(props);
|
|
40
40
|
const [pageMeta, setPageMeta] = useState({});
|
|
41
41
|
const [loading, setLoading] = useState(false);
|
|
42
42
|
const isFirstRender = useRef(true);
|
|
@@ -136,7 +136,6 @@ export function SPAProvider({
|
|
|
136
136
|
}
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
// Update OG tags
|
|
140
139
|
const ogUpdates = {
|
|
141
140
|
"og:title": meta.titleMeta,
|
|
142
141
|
"og:description": meta.descriptionMeta,
|
|
@@ -153,21 +152,21 @@ export function SPAProvider({
|
|
|
153
152
|
}, []);
|
|
154
153
|
|
|
155
154
|
useEffect(() => {
|
|
156
|
-
// Sync language from URL prefix if present
|
|
157
155
|
const urlLang = detectLangFromPath(location.pathname);
|
|
158
156
|
if (urlLang) {
|
|
159
157
|
setLang(urlLang);
|
|
160
158
|
} else if (languages.length > 0 && !isFirstRender.current) {
|
|
161
|
-
// On subsequent navigations, if prefix is missing, revert to default
|
|
162
159
|
setLang(defaultLanguage);
|
|
163
160
|
}
|
|
164
161
|
|
|
165
|
-
// Skip fetch on initial render
|
|
166
162
|
if (isFirstRender.current) {
|
|
167
163
|
isFirstRender.current = false;
|
|
168
164
|
return;
|
|
169
165
|
}
|
|
170
166
|
|
|
167
|
+
setPageProps({});
|
|
168
|
+
setPageMeta({});
|
|
169
|
+
|
|
171
170
|
if (abortRef.current) {
|
|
172
171
|
abortRef.current.abort();
|
|
173
172
|
}
|
|
@@ -4,9 +4,13 @@ import { useLanguage } from '../blue-bird/contexts/LanguageContext';
|
|
|
4
4
|
|
|
5
5
|
import Card from '../blue-bird/components/Card';
|
|
6
6
|
import Typography from '../blue-bird/components/Typography'
|
|
7
|
+
import { useSPA } from '../blue-bird/contexts/SPAContext.jsx';
|
|
7
8
|
|
|
8
9
|
export default function About() {
|
|
9
10
|
const { t } = useLanguage();
|
|
11
|
+
const { navigateToLang, pageProps, pageMeta } = useSPA();
|
|
12
|
+
console.log("pageProps", pageProps);
|
|
13
|
+
console.log("pageMeta", pageMeta);
|
|
10
14
|
return (
|
|
11
15
|
<div
|
|
12
16
|
className="bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 min-h-screen"
|
|
@@ -3,11 +3,16 @@ import Card from '../blue-bird/components/Card';
|
|
|
3
3
|
import Header from '../components/Header';
|
|
4
4
|
import { useLanguage } from '../blue-bird/contexts/LanguageContext';
|
|
5
5
|
import Typography from '../blue-bird/components/Typography';
|
|
6
|
+
import { useSPA } from '../blue-bird/contexts/SPAContext.jsx';
|
|
6
7
|
|
|
7
8
|
export default function Home() {
|
|
8
9
|
const { t } = useLanguage();
|
|
10
|
+
const { navigateToLang, pageProps, pageMeta } = useSPA();
|
|
11
|
+
console.log("pageProps", pageProps);
|
|
12
|
+
console.log("pageMeta", pageMeta);
|
|
13
|
+
|
|
9
14
|
useEffect(() => {
|
|
10
|
-
|
|
15
|
+
|
|
11
16
|
fetch("/api/login", {
|
|
12
17
|
method: "POST",
|
|
13
18
|
headers: {
|