@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
package/core/validate.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import xss from "xss";
|
|
2
|
+
|
|
3
|
+
const messages_default = {
|
|
4
|
+
es: {
|
|
5
|
+
required: (f) => `El campo ${f} es obligatorio`,
|
|
6
|
+
min: (f, n) => `El campo ${f} debe tener al menos ${n} caracteres`,
|
|
7
|
+
max: (f, n) => `El campo ${f} no puede tener más de ${n} caracteres`,
|
|
8
|
+
email: (f) => `El campo ${f} debe ser un email válido`,
|
|
9
|
+
number: (f) => `El campo ${f} debe ser numérico`,
|
|
10
|
+
alpha: (f) => `El campo ${f} solo puede contener letras`,
|
|
11
|
+
alphanumeric: (f) => `El campo ${f} solo puede contener letras y números`,
|
|
12
|
+
boolean: (f) => `El campo ${f} debe ser verdadero o falso`,
|
|
13
|
+
date: (f) => `El campo ${f} debe ser una fecha válida`,
|
|
14
|
+
url: (f) => `El campo ${f} debe ser una URL válida`,
|
|
15
|
+
in: (f, v) => `El campo ${f} debe ser uno de: ${v.join(", ")}`,
|
|
16
|
+
equals: (f, v) => `El campo ${f} debe ser igual a ${v}`,
|
|
17
|
+
password: () => `La contraseña debe tener mayúsculas, minúsculas y números`,
|
|
18
|
+
pattern: (f) => `El campo ${f} no cumple el patrón requerido`,
|
|
19
|
+
},
|
|
20
|
+
en: {
|
|
21
|
+
required: (f) => `${f} is required`,
|
|
22
|
+
min: (f, n) => `${f} must be at least ${n} characters`,
|
|
23
|
+
max: (f, n) => `${f} must be at most ${n} characters`,
|
|
24
|
+
email: (f) => `${f} must be a valid email`,
|
|
25
|
+
number: (f) => `${f} must be numeric`,
|
|
26
|
+
alpha: (f) => `${f} must contain only letters`,
|
|
27
|
+
alphanumeric: (f) => `${f} must contain only letters and numbers`,
|
|
28
|
+
boolean: (f) => `${f} must be true or false`,
|
|
29
|
+
date: (f) => `${f} must be a valid date`,
|
|
30
|
+
url: (f) => `${f} must be a valid URL`,
|
|
31
|
+
in: (f, v) => `${f} must be one of: ${v.join(", ")}`,
|
|
32
|
+
equals: (f, v) => `${f} must equal ${v}`,
|
|
33
|
+
password: () => `Password must contain uppercase, lowercase and numbers`,
|
|
34
|
+
pattern: (f) => `${f} does not match the required pattern`,
|
|
35
|
+
},
|
|
36
|
+
pt: {
|
|
37
|
+
required: (f) => `O campo ${f} é obrigatório`,
|
|
38
|
+
min: (f, n) => `O campo ${f} deve ter pelo menos ${n} caracteres`,
|
|
39
|
+
max: (f, n) => `O campo ${f} não pode ter mais de ${n} caracteres`,
|
|
40
|
+
email: (f) => `O campo ${f} deve ser um email válido`,
|
|
41
|
+
number: (f) => `O campo ${f} deve ser numérico`,
|
|
42
|
+
alpha: (f) => `O campo ${f} só pode conter letras`,
|
|
43
|
+
alphanumeric: (f) => `O campo ${f} só pode conter letras e números`,
|
|
44
|
+
boolean: (f) => `O campo ${f} deve ser verdadeiro ou falso`,
|
|
45
|
+
date: (f) => `O campo ${f} deve ser uma data válida`,
|
|
46
|
+
url: (f) => `O campo ${f} deve ser uma URL válida`,
|
|
47
|
+
in: (f, v) => `O campo ${f} deve ser um de: ${v.join(", ")}`,
|
|
48
|
+
equals: (f, v) => `O campo ${f} deve ser igual a ${v}`,
|
|
49
|
+
password: () => `A senha deve conter maiúsculas, minúsculas e números`,
|
|
50
|
+
pattern: (f) => `O campo ${f} não corresponde ao padrão exigido`,
|
|
51
|
+
},
|
|
52
|
+
br: {
|
|
53
|
+
required: (f) => `O campo ${f} é obrigatório`,
|
|
54
|
+
min: (f, n) => `O campo ${f} deve ter pelo menos ${n} caracteres`,
|
|
55
|
+
max: (f, n) => `O campo ${f} não pode ter mais de ${n} caracteres`,
|
|
56
|
+
email: (f) => `O campo ${f} deve ser um email válido`,
|
|
57
|
+
number: (f) => `O campo ${f} deve ser numérico`,
|
|
58
|
+
alpha: (f) => `O campo ${f} só pode conter letras`,
|
|
59
|
+
alphanumeric: (f) => `O campo ${f} só pode conter letras e números`,
|
|
60
|
+
boolean: (f) => `O campo ${f} deve ser verdadeiro ou falso`,
|
|
61
|
+
date: (f) => `O campo ${f} deve ser uma data válida`,
|
|
62
|
+
url: (f) => `O campo ${f} deve ser uma URL válida`,
|
|
63
|
+
in: (f, v) => `O campo ${f} deve ser um de: ${v.join(", ")}`,
|
|
64
|
+
equals: (f, v) => `O campo ${f} deve ser igual a ${v}`,
|
|
65
|
+
password: () => `A senha deve conter maiúsculas, minúsculas e números`,
|
|
66
|
+
pattern: (f) => `O campo ${f} não corresponde ao padrão exigido`,
|
|
67
|
+
},
|
|
68
|
+
fr: {
|
|
69
|
+
required: (f) => `Le champ ${f} est obligatoire`,
|
|
70
|
+
min: (f, n) => `Le champ ${f} doit contenir au moins ${n} caractères`,
|
|
71
|
+
max: (f, n) => `Le champ ${f} ne peut pas contenir plus de ${n} caractères`,
|
|
72
|
+
email: (f) => `Le champ ${f} doit être un email valide`,
|
|
73
|
+
number: (f) => `Le champ ${f} doit être numérique`,
|
|
74
|
+
alpha: (f) => `Le champ ${f} ne peut contenir que des lettres`,
|
|
75
|
+
alphanumeric: (f) =>
|
|
76
|
+
`Le champ ${f} ne peut contenir que des lettres et des chiffres`,
|
|
77
|
+
boolean: (f) => `Le champ ${f} doit être vrai ou faux`,
|
|
78
|
+
date: (f) => `Le champ ${f} doit être une date valide`,
|
|
79
|
+
url: (f) => `Le champ ${f} doit être une URL valide`,
|
|
80
|
+
in: (f, v) => `Le champ ${f} doit être l'un de: ${v.join(", ")}`,
|
|
81
|
+
equals: (f, v) => `Le champ ${f} doit être égal à ${v}`,
|
|
82
|
+
password: () =>
|
|
83
|
+
`Le mot de passe doit contenir des majuscules, des minuscules et des chiffres`,
|
|
84
|
+
pattern: (f) => `Le champ ${f} ne correspond pas au modèle requis`,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const validators = {
|
|
89
|
+
isEmpty: (value) => value === undefined || value === null || value === "",
|
|
90
|
+
isEmail: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
91
|
+
isNumeric: (value) => !isNaN(value) && !isNaN(parseFloat(value)),
|
|
92
|
+
isAlpha: (value) => /^[a-zA-Z]+$/.test(value),
|
|
93
|
+
isAlphanumeric: (value) => /^[a-zA-Z0-9]+$/.test(value),
|
|
94
|
+
isBoolean: (value) =>
|
|
95
|
+
value === true || value === false || value === "true" || value === "false",
|
|
96
|
+
isISO8601: (value) => !isNaN(Date.parse(value)),
|
|
97
|
+
isURL: (value) => {
|
|
98
|
+
try {
|
|
99
|
+
new URL(value);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
isLength: (value, { min, max }) => {
|
|
106
|
+
const len = String(value).length;
|
|
107
|
+
if (min !== undefined && len < min) return false;
|
|
108
|
+
if (max !== undefined && len > max) return false;
|
|
109
|
+
return true;
|
|
110
|
+
},
|
|
111
|
+
isIn: (value, values) => values.includes(value),
|
|
112
|
+
equals: (value, comparison) => value === comparison,
|
|
113
|
+
matches: (value, pattern) => pattern.test(value),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Comprehensive Validator class for handling multi-language data validation.
|
|
118
|
+
*/
|
|
119
|
+
class Validator {
|
|
120
|
+
/**
|
|
121
|
+
* Initializes the Validator instance with a schema and optional language settings.
|
|
122
|
+
* @param {Object} schema - Validation rules for each field (e.g., { email: { required: true, email: true } }).
|
|
123
|
+
* @param {string} [lang_default=null] - Default language for error messages (e.g., "en", "es").
|
|
124
|
+
* @param {Object} [messages=null] - Custom message overrides for validation rules.
|
|
125
|
+
* @example
|
|
126
|
+
* const loginSchema = {
|
|
127
|
+
* email: { required: true, email: true },
|
|
128
|
+
* password: { required: true, min: 6 }
|
|
129
|
+
* };
|
|
130
|
+
* const loginValidator = new Validator(loginSchema, 'es');
|
|
131
|
+
*/
|
|
132
|
+
constructor(schema, lang_default = null, messages = null) {
|
|
133
|
+
this.schema = schema;
|
|
134
|
+
this.lang_default = lang_default;
|
|
135
|
+
this.messages = messages ? messages : messages_default;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validates the request body against the defined schema.
|
|
140
|
+
* @param {import('express').Request} req - The Express request object containing the body to validate.
|
|
141
|
+
* @returns {Promise<{success: boolean, error: boolean, errors: Array<{field: string, message: string}>, message: Array<string>, html: Array<string>}>} Validation results.
|
|
142
|
+
* @example
|
|
143
|
+
* const loginSchema = {
|
|
144
|
+
* email: { required: true, email: true },
|
|
145
|
+
* password: { required: true, min: 6 }
|
|
146
|
+
* };
|
|
147
|
+
* const loginValidator = new Validator(loginSchema, 'es');
|
|
148
|
+
* const result = await loginValidator.validate(req);
|
|
149
|
+
*/
|
|
150
|
+
async validate(req) {
|
|
151
|
+
let lang =
|
|
152
|
+
req?.body?.lang ||
|
|
153
|
+
req?.query?.lang ||
|
|
154
|
+
req?.params?.lang ||
|
|
155
|
+
req?.cookies?.lang ||
|
|
156
|
+
req?.headers["accept-language"]?.split(",")[0]?.split("-")[0] ||
|
|
157
|
+
req?.session?.lang ||
|
|
158
|
+
this.lang_default ||
|
|
159
|
+
"es";
|
|
160
|
+
const msg = this.messages[lang] || this.messages.es;
|
|
161
|
+
const errors = [];
|
|
162
|
+
const messages = [];
|
|
163
|
+
const body = req.body || {};
|
|
164
|
+
|
|
165
|
+
for (const [field, config] of Object.entries(this.schema)) {
|
|
166
|
+
let value = body[field];
|
|
167
|
+
|
|
168
|
+
if (config.xss !== false && typeof value === "string") {
|
|
169
|
+
body[field] = xss(value);
|
|
170
|
+
value = body[field];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (config.required && validators.isEmpty(value)) {
|
|
174
|
+
messages.push(config.messages?.required || msg.required(field));
|
|
175
|
+
errors.push({
|
|
176
|
+
field: field,
|
|
177
|
+
message: config.messages?.required || msg.required(field),
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!validators.isEmpty(value)) {
|
|
183
|
+
if (
|
|
184
|
+
config.min !== undefined &&
|
|
185
|
+
!validators.isLength(value, { min: config.min })
|
|
186
|
+
) {
|
|
187
|
+
messages.push(config.messages?.min || msg.min(field, config.min));
|
|
188
|
+
errors.push({
|
|
189
|
+
field: field,
|
|
190
|
+
message: config.messages?.min || msg.min(field, config.min),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (
|
|
194
|
+
config.max !== undefined &&
|
|
195
|
+
!validators.isLength(value, { max: config.max })
|
|
196
|
+
) {
|
|
197
|
+
messages.push(config.messages?.max || msg.max(field, config.max));
|
|
198
|
+
errors.push({
|
|
199
|
+
field: field,
|
|
200
|
+
message: config.messages?.max || msg.max(field, config.max),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (config.email && !validators.isEmail(value)) {
|
|
204
|
+
messages.push(config.messages?.email || msg.email(field));
|
|
205
|
+
errors.push({
|
|
206
|
+
field: field,
|
|
207
|
+
message: config.messages?.email || msg.email(field),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (config.number && !validators.isNumeric(value)) {
|
|
211
|
+
messages.push(config.messages?.number || msg.number(field));
|
|
212
|
+
errors.push({
|
|
213
|
+
field: field,
|
|
214
|
+
message: config.messages?.number || msg.number(field),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (config.alpha && !validators.isAlpha(value)) {
|
|
218
|
+
messages.push(config.messages?.alpha || msg.alpha(field));
|
|
219
|
+
errors.push({
|
|
220
|
+
field: field,
|
|
221
|
+
message: config.messages?.alpha || msg.alpha(field),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (config.alphanumeric && !validators.isAlphanumeric(value)) {
|
|
225
|
+
messages.push(
|
|
226
|
+
config.messages?.alphanumeric || msg.alphanumeric(field),
|
|
227
|
+
);
|
|
228
|
+
errors.push({
|
|
229
|
+
field: field,
|
|
230
|
+
message: config.messages?.alphanumeric || msg.alphanumeric(field),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (config.boolean && !validators.isBoolean(value)) {
|
|
234
|
+
messages.push(config.messages?.boolean || msg.boolean(field));
|
|
235
|
+
errors.push({
|
|
236
|
+
field: field,
|
|
237
|
+
message: config.messages?.boolean || msg.boolean(field),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (config.date && !validators.isISO8601(value)) {
|
|
241
|
+
messages.push(config.messages?.date || msg.date(field));
|
|
242
|
+
errors.push({
|
|
243
|
+
field: field,
|
|
244
|
+
message: config.messages?.date || msg.date(field),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (config.url && !validators.isURL(value)) {
|
|
248
|
+
messages.push(config.messages?.url || msg.url(field));
|
|
249
|
+
errors.push({
|
|
250
|
+
field: field,
|
|
251
|
+
message: config.messages?.url || msg.url(field),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (config.in && !validators.isIn(value, config.in)) {
|
|
255
|
+
messages.push(config.messages?.in || msg.in(field, config.in));
|
|
256
|
+
errors.push({
|
|
257
|
+
field: field,
|
|
258
|
+
message: config.messages?.in || msg.in(field, config.in),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
config.equals !== undefined &&
|
|
263
|
+
!validators.equals(value, config.equals)
|
|
264
|
+
) {
|
|
265
|
+
messages.push(
|
|
266
|
+
config.messages?.equals || msg.equals(field, config.equals),
|
|
267
|
+
);
|
|
268
|
+
errors.push({
|
|
269
|
+
field: field,
|
|
270
|
+
message:
|
|
271
|
+
config.messages?.equals || msg.equals(field, config.equals),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
config.password &&
|
|
276
|
+
!validators.matches(value, /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{6,}$/)
|
|
277
|
+
) {
|
|
278
|
+
messages.push(config.messages?.password || msg.password(field));
|
|
279
|
+
errors.push({
|
|
280
|
+
field: field,
|
|
281
|
+
message: config.messages?.password || msg.password(field),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (config.pattern && !validators.matches(value, config.pattern)) {
|
|
285
|
+
messages.push(config.messages?.pattern || msg.pattern(field));
|
|
286
|
+
errors.push({
|
|
287
|
+
field: field,
|
|
288
|
+
message: config.messages?.pattern || msg.pattern(field),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (errors.length > 0 || messages.length > 0) {
|
|
295
|
+
return {
|
|
296
|
+
success: false,
|
|
297
|
+
error: true,
|
|
298
|
+
errors: errors,
|
|
299
|
+
message: messages,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { success: true, error: false, errors: [], message: [], html: [] };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Express middleware for automated validation of the request body.
|
|
308
|
+
* Returns a 400 Bad Request response with validation results if errors occur.
|
|
309
|
+
* @returns {Function} Express middleware function (req, res, next).
|
|
310
|
+
* @example
|
|
311
|
+
*
|
|
312
|
+
* const loginSchema = {
|
|
313
|
+
* email: { required: true, email: true },
|
|
314
|
+
* password: { required: true, min: 6 }
|
|
315
|
+
* };
|
|
316
|
+
* const loginValidator = new Validator(loginSchema, 'es');
|
|
317
|
+
* routerUsers.post('/login', loginValidator.middleware(), (req, res) => {
|
|
318
|
+
* res.json({ message: 'Login successful' });
|
|
319
|
+
* });
|
|
320
|
+
*/
|
|
321
|
+
middleware() {
|
|
322
|
+
return async (req, res, next) => {
|
|
323
|
+
const result = await this.validate(req);
|
|
324
|
+
if (!result.success) {
|
|
325
|
+
return res.status(400).json(result);
|
|
326
|
+
}
|
|
327
|
+
next();
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Express middleware for explicitly sanitizing request data against XSS.
|
|
333
|
+
* Can be used on specific routes where HTML input is not expected.
|
|
334
|
+
* @returns {Function} Express middleware function.
|
|
335
|
+
*/
|
|
336
|
+
static xssMiddleware() {
|
|
337
|
+
return (req, res, next) => {
|
|
338
|
+
if (req.body && typeof req.body === "object") {
|
|
339
|
+
Validator.mutateSanitized(req.body);
|
|
340
|
+
}
|
|
341
|
+
if (req.query && typeof req.query === "object") {
|
|
342
|
+
Validator.mutateSanitized(req.query);
|
|
343
|
+
}
|
|
344
|
+
if (req.params && typeof req.params === "object") {
|
|
345
|
+
Validator.mutateSanitized(req.params);
|
|
346
|
+
}
|
|
347
|
+
next();
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
static mutateSanitized(obj) {
|
|
352
|
+
for (const key in obj) {
|
|
353
|
+
if (typeof obj[key] === "string") {
|
|
354
|
+
obj[key] = xss(obj[key]);
|
|
355
|
+
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
356
|
+
Validator.mutateSanitized(obj[key]);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
static sanitizeObject(obj) {
|
|
362
|
+
if (typeof obj === "string") return xss(obj);
|
|
363
|
+
|
|
364
|
+
if (Array.isArray(obj)) {
|
|
365
|
+
return obj.map((item) => Validator.sanitizeObject(item));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (typeof obj === "object" && obj !== null) {
|
|
369
|
+
const sanitized = {};
|
|
370
|
+
for (const key in obj) {
|
|
371
|
+
sanitized[key] = Validator.sanitizeObject(obj[key]);
|
|
372
|
+
}
|
|
373
|
+
return sanitized;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return obj;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export default Validator;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# Blue Bird Framework — Production Dockerfile
|
|
3
|
+
# Node.js 24-alpine for lightweight, production-ready environment
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
FROM node:24.7.0-alpine3.21
|
|
6
|
+
|
|
7
|
+
# Set environment variables
|
|
8
|
+
ENV NODE_ENV=production
|
|
9
|
+
|
|
10
|
+
WORKDIR /app
|
|
11
|
+
|
|
12
|
+
# Copy package files first (layer cache optimization)
|
|
13
|
+
COPY package*.json ./
|
|
14
|
+
|
|
15
|
+
# Install production dependencies
|
|
16
|
+
RUN npm ci --omit=dev
|
|
17
|
+
|
|
18
|
+
# Copy project source
|
|
19
|
+
COPY . .
|
|
20
|
+
|
|
21
|
+
# Expose the application port
|
|
22
|
+
EXPOSE 3000
|
|
23
|
+
|
|
24
|
+
# Start the application
|
|
25
|
+
CMD ["npm", "start"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# Blue Bird Framework — Docker Compose
|
|
3
|
+
#
|
|
4
|
+
# DEVELOPMENT / DATABASE ONLY:
|
|
5
|
+
# npx blue-bird docker start mysql → Only MySQL container
|
|
6
|
+
#
|
|
7
|
+
# PRODUCTION:
|
|
8
|
+
# npx blue-bird docker start prod → MySQL + Node.js app container
|
|
9
|
+
# npx blue-bird docker stop → Stop all containers
|
|
10
|
+
#
|
|
11
|
+
# Multi-project VPS support:
|
|
12
|
+
# Each project uses TITLE (or sanitised TITLE) to name
|
|
13
|
+
# containers and networks uniquely.
|
|
14
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
services:
|
|
17
|
+
# ── Node.js App (Production only) ─────────────────────────────────────────
|
|
18
|
+
app:
|
|
19
|
+
build:
|
|
20
|
+
context: .
|
|
21
|
+
dockerfile: docker/Dockerfile
|
|
22
|
+
container_name: ${TITLE:-bluebird}-app
|
|
23
|
+
restart: unless-stopped
|
|
24
|
+
ports:
|
|
25
|
+
- "${PORT:-3000}:3000"
|
|
26
|
+
volumes:
|
|
27
|
+
- .:/app
|
|
28
|
+
- /app/node_modules
|
|
29
|
+
env_file:
|
|
30
|
+
- .env
|
|
31
|
+
environment:
|
|
32
|
+
- NODE_ENV=production
|
|
33
|
+
- DEBUG=false
|
|
34
|
+
- DATABASE_URL=mysql://root:${DB_PASSWORD:-root}@mysql:3306/${DB_NAME:-blue_bird}
|
|
35
|
+
depends_on:
|
|
36
|
+
mysql:
|
|
37
|
+
condition: service_healthy
|
|
38
|
+
networks:
|
|
39
|
+
- bluebird_net
|
|
40
|
+
profiles:
|
|
41
|
+
- prod
|
|
42
|
+
|
|
43
|
+
# ── MySQL Database ─────────────────────────────────────────────────────────
|
|
44
|
+
mysql:
|
|
45
|
+
image: mysql:8.0
|
|
46
|
+
container_name: ${TITLE:-bluebird}-mysql
|
|
47
|
+
restart: unless-stopped
|
|
48
|
+
environment:
|
|
49
|
+
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root}
|
|
50
|
+
MYSQL_DATABASE: ${DB_NAME:-blue_bird}
|
|
51
|
+
ports:
|
|
52
|
+
- "${DB_PORT:-3306}:3306"
|
|
53
|
+
volumes:
|
|
54
|
+
- mysql_data:/var/lib/mysql
|
|
55
|
+
healthcheck:
|
|
56
|
+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-root}"]
|
|
57
|
+
interval: 10s
|
|
58
|
+
timeout: 5s
|
|
59
|
+
retries: 5
|
|
60
|
+
start_period: 30s
|
|
61
|
+
networks:
|
|
62
|
+
- bluebird_net
|
|
63
|
+
|
|
64
|
+
volumes:
|
|
65
|
+
mysql_data:
|
|
66
|
+
|
|
67
|
+
networks:
|
|
68
|
+
bluebird_net:
|
|
69
|
+
name: ${TITLE:-bluebird}_network
|
|
70
|
+
driver: bridge
|
|
Binary file
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blueBird-SPA Navigation Engine
|
|
3
|
+
* Provides Single Page Application behavior for Twig and React.
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
const CONTENT_ID = 'blueBird-spa-content';
|
|
7
|
+
const TIMEOUT_MS = 5000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Navigates to a new page using SPA logic.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} url - The target URL
|
|
13
|
+
* @param {boolean} [push=true] - Whether to push to browser history
|
|
14
|
+
* @returns {Promise<void>}
|
|
15
|
+
*/
|
|
16
|
+
async function navigate(url, push = true) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
let requestUrl = url;
|
|
22
|
+
|
|
23
|
+
const separator = requestUrl.includes('?') ? '&' : '?';
|
|
24
|
+
const response = await fetch(`${requestUrl}${separator}source=frontend`, {
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
headers: {
|
|
27
|
+
'X-blueBird-SPA': 'true',
|
|
28
|
+
'Accept': 'application/json'
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
clearTimeout(timeoutId);
|
|
33
|
+
|
|
34
|
+
if (response.status === 401 || response.status === 403) {
|
|
35
|
+
window.location.href = url;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
|
|
45
|
+
if (data.meta) {
|
|
46
|
+
document.title = data.meta.title || document.title;
|
|
47
|
+
['description', 'keywords', 'author'].forEach(name => {
|
|
48
|
+
const el = document.querySelector(`meta[name="${name}"]`);
|
|
49
|
+
if (el && data.meta[name]) el.setAttribute("content", data.meta[name]);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (data.css && Array.isArray(data.css)) {
|
|
54
|
+
data.css.forEach(href => {
|
|
55
|
+
if (!document.head.querySelector(`link[href="${href}"]`)) {
|
|
56
|
+
const link = document.createElement('link');
|
|
57
|
+
link.rel = 'stylesheet';
|
|
58
|
+
link.href = href;
|
|
59
|
+
document.head.appendChild(link);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const container = document.getElementById(CONTENT_ID);
|
|
65
|
+
if (container) {
|
|
66
|
+
container.innerHTML = data.body;
|
|
67
|
+
|
|
68
|
+
const partialScripts = container.querySelectorAll('script');
|
|
69
|
+
const partialLinks = container.querySelectorAll('link[rel="stylesheet"]');
|
|
70
|
+
|
|
71
|
+
partialLinks.forEach(link => {
|
|
72
|
+
if (!document.head.querySelector(`link[href="${link.href}"]`)) {
|
|
73
|
+
document.head.appendChild(link.cloneNode(true));
|
|
74
|
+
}
|
|
75
|
+
link.remove();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
partialScripts.forEach(oldScript => {
|
|
79
|
+
const newScript = document.createElement('script');
|
|
80
|
+
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
|
81
|
+
newScript.textContent = oldScript.textContent;
|
|
82
|
+
|
|
83
|
+
if (oldScript.src) {
|
|
84
|
+
const existing = document.head.querySelector(`script[src="${oldScript.src}"]`);
|
|
85
|
+
if (existing && (oldScript.hasAttribute('data-spa-reload') || oldScript.src.includes('source=frontend'))) {
|
|
86
|
+
existing.remove();
|
|
87
|
+
}
|
|
88
|
+
if (!document.head.querySelector(`script[src="${oldScript.src}"]`)) {
|
|
89
|
+
document.head.appendChild(newScript);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const content = oldScript.textContent.trim();
|
|
93
|
+
if (content) {
|
|
94
|
+
Array.from(document.head.querySelectorAll('script:not([src])'))
|
|
95
|
+
.filter(s => s.textContent.trim() === content)
|
|
96
|
+
.forEach(s => s.remove());
|
|
97
|
+
document.head.appendChild(newScript);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
oldScript.remove();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!url.includes('set-lang=true')) {
|
|
104
|
+
const hash = url.split('#')[1];
|
|
105
|
+
if (hash) {
|
|
106
|
+
const element = document.getElementById(hash);
|
|
107
|
+
if (element) {
|
|
108
|
+
element.scrollIntoView({ behavior: 'smooth' });
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (push) {
|
|
117
|
+
let finalUrl = response.url || url;
|
|
118
|
+
try {
|
|
119
|
+
const urlObj = new URL(finalUrl);
|
|
120
|
+
const hasSetLang = urlObj.searchParams.has('set-lang');
|
|
121
|
+
urlObj.searchParams.delete('source');
|
|
122
|
+
urlObj.searchParams.delete('set-lang');
|
|
123
|
+
if (hasSetLang) {
|
|
124
|
+
urlObj.searchParams.delete('lang');
|
|
125
|
+
}
|
|
126
|
+
finalUrl = urlObj.pathname + urlObj.search + urlObj.hash;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
finalUrl = finalUrl.replace(/[?&]source=frontend/, '').replace(/[?&]set-lang=true/, '');
|
|
129
|
+
}
|
|
130
|
+
window.history.pushState({ url: finalUrl }, data.meta?.title || '', finalUrl);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
document.dispatchEvent(new CustomEvent('blueBird:navigation', {
|
|
134
|
+
detail: { url, data }
|
|
135
|
+
}));
|
|
136
|
+
document.dispatchEvent(new CustomEvent('blueBird:content-loaded', {
|
|
137
|
+
detail: { url, data }
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.name === 'AbortError') {
|
|
142
|
+
console.warn('SPA Navigation timeout, redirecting...');
|
|
143
|
+
} else {
|
|
144
|
+
console.error('SPA Navigation failed:', error);
|
|
145
|
+
}
|
|
146
|
+
window.location.href = url;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
window.blueBirdNav = { navigate };
|
|
151
|
+
|
|
152
|
+
function initSPA() {
|
|
153
|
+
document.addEventListener('click', (e) => {
|
|
154
|
+
const link = e.target.closest('a');
|
|
155
|
+
|
|
156
|
+
if (link &&
|
|
157
|
+
link.href &&
|
|
158
|
+
link.href.startsWith(window.location.origin) &&
|
|
159
|
+
!link.hasAttribute('download') &&
|
|
160
|
+
!link.hasAttribute('data-no-spa') &&
|
|
161
|
+
link.target !== '_blank' &&
|
|
162
|
+
!e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
163
|
+
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
navigate(link.href);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
window.addEventListener('popstate', (e) => {
|
|
170
|
+
if (e.state && e.state.url) {
|
|
171
|
+
navigate(e.state.url, false);
|
|
172
|
+
} else {
|
|
173
|
+
window.location.reload();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (document.readyState === 'loading') {
|
|
179
|
+
document.addEventListener('DOMContentLoaded', initSPA);
|
|
180
|
+
} else {
|
|
181
|
+
initSPA();
|
|
182
|
+
}
|
|
183
|
+
})();
|