@nitronjs/framework 0.2.24 → 0.2.27
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/cli/njs.js +2 -2
- package/lib/Build/FileAnalyzer.js +1 -1
- package/lib/Build/Manager.js +40 -4
- package/lib/Database/DB.js +33 -5
- package/lib/Database/Drivers/MySQLDriver.js +7 -3
- package/lib/Database/Model.js +21 -9
- package/lib/Database/QueryBuilder.js +116 -27
- package/lib/Database/QueryValidation.js +32 -0
- package/lib/Database/Schema/Blueprint.js +28 -4
- package/lib/Database/Schema/Manager.js +47 -5
- package/lib/Encryption/Encryption.js +19 -11
- package/lib/Filesystem/Storage.js +3 -1
- package/lib/Http/Server.js +12 -2
- package/lib/Logging/Log.js +9 -3
- package/lib/Route/Router.js +36 -17
- package/lib/Session/Manager.js +57 -5
- package/lib/Session/Redis.js +117 -0
- package/lib/View/View.js +16 -15
- package/lib/index.d.ts +55 -11
- package/package.json +2 -1
- package/skeleton/app/Middlewares/VerifyCsrf.js +2 -2
- package/skeleton/config/session.js +1 -0
package/cli/njs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const COLORS = {
|
|
4
4
|
reset: "\x1b[0m",
|
|
@@ -183,7 +183,7 @@ async function run() {
|
|
|
183
183
|
if (exitCode !== null) {
|
|
184
184
|
const { default: checkForUpdates } = await import("../lib/Console/UpdateChecker.js");
|
|
185
185
|
await checkForUpdates();
|
|
186
|
-
process.exitCode
|
|
186
|
+
process.exit(exitCode);
|
|
187
187
|
}
|
|
188
188
|
} catch (error) {
|
|
189
189
|
console.error(`${COLORS.red}Error: ${error.message}${COLORS.reset}`);
|
package/lib/Build/Manager.js
CHANGED
|
@@ -309,7 +309,7 @@ class Builder {
|
|
|
309
309
|
return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
const { entries, layouts, meta } = this.#analyzer.discoverEntries(srcDir);
|
|
312
|
+
const { entries, layouts, meta, importedBy } = this.#analyzer.discoverEntries(srcDir);
|
|
313
313
|
|
|
314
314
|
if (!entries.length && !layouts.length) {
|
|
315
315
|
return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
|
|
@@ -318,19 +318,36 @@ class Builder {
|
|
|
318
318
|
this.#addToManifest(entries, layouts, meta, srcDir, namespace);
|
|
319
319
|
|
|
320
320
|
const allFiles = [...entries, ...layouts];
|
|
321
|
-
const
|
|
321
|
+
const entryLayoutSet = new Set(allFiles);
|
|
322
|
+
const changedSources = new Set();
|
|
322
323
|
|
|
323
|
-
for (const file of
|
|
324
|
+
for (const file of meta.keys()) {
|
|
324
325
|
const content = await fs.promises.readFile(file, "utf8");
|
|
325
326
|
const hash = crypto.createHash("md5").update(content).digest("hex");
|
|
326
327
|
const cachedHash = this.#cache.viewHashes.get(file);
|
|
327
328
|
|
|
328
329
|
if (cachedHash !== hash) {
|
|
329
330
|
this.#cache.viewHashes.set(file, hash);
|
|
330
|
-
|
|
331
|
+
changedSources.add(file);
|
|
331
332
|
}
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
const filesToBuild = new Set();
|
|
336
|
+
|
|
337
|
+
for (const file of changedSources) {
|
|
338
|
+
if (entryLayoutSet.has(file)) {
|
|
339
|
+
filesToBuild.add(file);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const dep of this.#getTransitiveDependents(file, importedBy)) {
|
|
343
|
+
if (entryLayoutSet.has(dep)) {
|
|
344
|
+
filesToBuild.add(dep);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const changedFiles = [...filesToBuild];
|
|
350
|
+
|
|
334
351
|
if (changedFiles.length) {
|
|
335
352
|
this.#cache.viewsChanged = true;
|
|
336
353
|
await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir });
|
|
@@ -444,6 +461,25 @@ class Builder {
|
|
|
444
461
|
this.#stats.islands = hydrationFiles.length;
|
|
445
462
|
}
|
|
446
463
|
|
|
464
|
+
#getTransitiveDependents(file, importedBy) {
|
|
465
|
+
const result = new Set();
|
|
466
|
+
const queue = [file];
|
|
467
|
+
|
|
468
|
+
while (queue.length > 0) {
|
|
469
|
+
const current = queue.shift();
|
|
470
|
+
const dependents = importedBy.get(current) || [];
|
|
471
|
+
|
|
472
|
+
for (const dep of dependents) {
|
|
473
|
+
if (!result.has(dep)) {
|
|
474
|
+
result.add(dep);
|
|
475
|
+
queue.push(dep);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
|
|
447
483
|
async #buildCss() {
|
|
448
484
|
this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged);
|
|
449
485
|
}
|
package/lib/Database/DB.js
CHANGED
|
@@ -74,17 +74,18 @@ class DB {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Executes a raw SQL query.
|
|
78
|
-
* @param {string} sql - Raw SQL string
|
|
77
|
+
* Executes a raw SQL query with optional parameter bindings.
|
|
78
|
+
* @param {string} sql - Raw SQL string (use ? for parameter placeholders)
|
|
79
|
+
* @param {Array} bindings - Parameter bindings for safe query execution
|
|
79
80
|
* @returns {Promise<Array>} Query result
|
|
80
81
|
* @throws {Error} If database is disabled
|
|
81
82
|
*/
|
|
82
|
-
static async rawQuery(sql) {
|
|
83
|
+
static async rawQuery(sql, bindings = []) {
|
|
83
84
|
if (!this.#driver) {
|
|
84
85
|
throw new Error("Database is disabled (DATABASE_DRIVER=none)");
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
return await this.#driver.raw(sql);
|
|
88
|
+
return await this.#driver.raw(sql, bindings);
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/**
|
|
@@ -105,7 +106,27 @@ class DB {
|
|
|
105
106
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
106
107
|
|
|
107
108
|
try {
|
|
108
|
-
const result = await this.#driver.withTransaction(
|
|
109
|
+
const result = await this.#driver.withTransaction((connection) => {
|
|
110
|
+
const connectionWrapper = {
|
|
111
|
+
query: (sql, bindings) => connection.execute(sql, bindings)
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const trx = {
|
|
115
|
+
table: (table, modelClass = null) => createQueryBuilder(table, connectionWrapper, modelClass),
|
|
116
|
+
rawQuery: (sql, bindings = []) => {
|
|
117
|
+
if (bindings.length > 0) {
|
|
118
|
+
return connection.execute(sql, bindings);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return connection.query(sql);
|
|
122
|
+
},
|
|
123
|
+
query: (...args) => connection.query(...args),
|
|
124
|
+
execute: (...args) => connection.execute(...args)
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return callback(trx);
|
|
128
|
+
}, { signal: controller.signal });
|
|
129
|
+
|
|
109
130
|
clearTimeout(timeoutId);
|
|
110
131
|
|
|
111
132
|
return result;
|
|
@@ -147,6 +168,13 @@ class DB {
|
|
|
147
168
|
};
|
|
148
169
|
|
|
149
170
|
this.#driver = this.#createDriver("mysql");
|
|
171
|
+
|
|
172
|
+
const isHealthy = await this.#driver.healthCheck();
|
|
173
|
+
|
|
174
|
+
if (!isHealthy) {
|
|
175
|
+
this.#driver = null;
|
|
176
|
+
throw new Error("Database reconfiguration failed: health check failed. Check your credentials.");
|
|
177
|
+
}
|
|
150
178
|
}
|
|
151
179
|
|
|
152
180
|
/** @private */
|
|
@@ -30,11 +30,11 @@ class MySQLDriver {
|
|
|
30
30
|
charset: this.#config.charset || "utf8mb4",
|
|
31
31
|
waitForConnections: true,
|
|
32
32
|
connectionLimit: this.#config.pool?.max ?? 10,
|
|
33
|
+
idleTimeout: this.#config.pool?.idleTimeout ?? 60000,
|
|
33
34
|
queueLimit: this.#config.pool?.queueLimit ?? 100,
|
|
34
35
|
connectTimeout: 10000,
|
|
35
36
|
enableKeepAlive: true,
|
|
36
37
|
keepAliveInitialDelay: 10000,
|
|
37
|
-
namedPlaceholders: true,
|
|
38
38
|
decimalNumbers: true
|
|
39
39
|
});
|
|
40
40
|
}
|
|
@@ -67,12 +67,16 @@ class MySQLDriver {
|
|
|
67
67
|
* @param {string} sql - Raw SQL string
|
|
68
68
|
* @returns {Promise<Array>} Query result
|
|
69
69
|
*/
|
|
70
|
-
async raw(sql) {
|
|
70
|
+
async raw(sql, bindings = []) {
|
|
71
71
|
try {
|
|
72
|
+
if (bindings.length > 0) {
|
|
73
|
+
return await this.#pool.execute(sql, bindings);
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
return await this.#pool.query(sql);
|
|
73
77
|
}
|
|
74
78
|
catch (error) {
|
|
75
|
-
throw this.#handleError(error, sql);
|
|
79
|
+
throw this.#handleError(error, sql, bindings.length > 0 ? bindings : null);
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
package/lib/Database/Model.js
CHANGED
|
@@ -175,18 +175,30 @@ class Model {
|
|
|
175
175
|
*/
|
|
176
176
|
async save() {
|
|
177
177
|
const table = this.constructor.table;
|
|
178
|
-
const data = {};
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
179
|
+
if (this._exists) {
|
|
180
|
+
const dirty = {};
|
|
181
|
+
|
|
182
|
+
for (const [key, value] of Object.entries(this._attributes)) {
|
|
183
|
+
if (key === 'id') continue;
|
|
184
|
+
if (value !== this._original[key]) {
|
|
185
|
+
dirty[key] = value;
|
|
186
|
+
}
|
|
183
187
|
}
|
|
184
|
-
}
|
|
185
188
|
|
|
186
|
-
|
|
187
|
-
|
|
189
|
+
if (Object.keys(dirty).length > 0) {
|
|
190
|
+
await DB.table(table).where('id', this._attributes.id).update(dirty);
|
|
191
|
+
}
|
|
188
192
|
}
|
|
189
193
|
else {
|
|
194
|
+
const data = {};
|
|
195
|
+
|
|
196
|
+
for (const [key, value] of Object.entries(this._attributes)) {
|
|
197
|
+
if (value !== undefined) {
|
|
198
|
+
data[key] = value;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
190
202
|
const id = await DB.table(table).insert(data);
|
|
191
203
|
this._attributes.id = id;
|
|
192
204
|
this._exists = true;
|
|
@@ -264,10 +276,10 @@ function hydrate(modelClass, row) {
|
|
|
264
276
|
|
|
265
277
|
const QUERY_METHODS = [
|
|
266
278
|
// Chain methods (return QueryBuilder)
|
|
267
|
-
'distinct', 'orWhere', 'whereIn', 'whereNotIn',
|
|
279
|
+
'selectRaw', 'distinct', 'orWhere', 'whereIn', 'whereNotIn',
|
|
268
280
|
'whereBetween', 'whereNot', 'join', 'groupBy', 'offset',
|
|
269
281
|
// Terminal methods (return results)
|
|
270
|
-
'count', 'max', 'min', 'sum', 'avg', 'delete'
|
|
282
|
+
'count', 'countDistinct', 'max', 'min', 'sum', 'avg', 'delete'
|
|
271
283
|
];
|
|
272
284
|
|
|
273
285
|
for (const method of QUERY_METHODS) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateDirection, validateIdentifier, validateWhereOperator } from "./QueryValidation.js";
|
|
1
|
+
import { validateDirection, validateIdentifier, validateRawExpression, validateWhereOperator } from "./QueryValidation.js";
|
|
2
2
|
import Environment from "../Core/Environment.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -22,7 +22,6 @@ class QueryBuilder {
|
|
|
22
22
|
#joins = [];
|
|
23
23
|
#orders = [];
|
|
24
24
|
#groups = [];
|
|
25
|
-
#havings = [];
|
|
26
25
|
#limitValue = null;
|
|
27
26
|
#offsetValue = null;
|
|
28
27
|
#distinctFlag = false;
|
|
@@ -90,19 +89,21 @@ class QueryBuilder {
|
|
|
90
89
|
return operator;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
#validateInteger(value) {
|
|
92
|
+
#validateInteger(value, maxAllowed = 10000000) {
|
|
94
93
|
const stringValue = String(value).trim();
|
|
94
|
+
|
|
95
95
|
if (!/^\d+$/.test(stringValue)) {
|
|
96
96
|
throw new Error(`Invalid integer: "${value}". Must be a pure positive number.`);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const num = parseInt(stringValue, 10);
|
|
100
|
+
|
|
100
101
|
if (isNaN(num) || num < 0) {
|
|
101
102
|
throw new Error(`Invalid integer: "${value}". Must be a non-negative number.`);
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
if (num >
|
|
105
|
-
throw new Error(`Invalid integer: "${value}". Maximum allowed value is
|
|
105
|
+
if (num > maxAllowed) {
|
|
106
|
+
throw new Error(`Invalid integer: "${value}". Maximum allowed value is ${maxAllowed}.`);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
return num;
|
|
@@ -157,7 +158,6 @@ class QueryBuilder {
|
|
|
157
158
|
this.#joins = [];
|
|
158
159
|
this.#orders = [];
|
|
159
160
|
this.#groups = [];
|
|
160
|
-
this.#havings = [];
|
|
161
161
|
this.#limitValue = null;
|
|
162
162
|
this.#offsetValue = null;
|
|
163
163
|
this.#distinctFlag = false;
|
|
@@ -179,6 +179,45 @@ class QueryBuilder {
|
|
|
179
179
|
return this;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Add a raw expression to the SELECT clause.
|
|
184
|
+
* If the current selection is the default '*', replaces it.
|
|
185
|
+
* Otherwise appends to existing selections.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} expression - Raw SQL expression
|
|
188
|
+
* @returns {QueryBuilder}
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* // Standalone
|
|
192
|
+
* DB.table("orders").selectRaw("COUNT(*) as total").first();
|
|
193
|
+
*
|
|
194
|
+
* // Combined with select
|
|
195
|
+
* DB.table("orders")
|
|
196
|
+
* .select("status")
|
|
197
|
+
* .selectRaw("COUNT(*) as total")
|
|
198
|
+
* .selectRaw("AVG(amount) as avg_amount")
|
|
199
|
+
* .groupBy("status")
|
|
200
|
+
* .get();
|
|
201
|
+
*/
|
|
202
|
+
selectRaw(expression) {
|
|
203
|
+
if (typeof expression !== 'string' || expression.trim() === '') {
|
|
204
|
+
throw new Error('selectRaw() requires a non-empty string expression.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
validateRawExpression(expression);
|
|
208
|
+
|
|
209
|
+
const raw = new RawExpression(expression);
|
|
210
|
+
|
|
211
|
+
if (this.#selectColumns.length === 1 && this.#selectColumns[0] === '*') {
|
|
212
|
+
this.#selectColumns = [raw];
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
this.#selectColumns.push(raw);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
182
221
|
distinct() {
|
|
183
222
|
this.#distinctFlag = true;
|
|
184
223
|
|
|
@@ -206,6 +245,19 @@ class QueryBuilder {
|
|
|
206
245
|
operator = this.#validateWhereOperator(operator);
|
|
207
246
|
value = this.#validateWhereValue(value);
|
|
208
247
|
|
|
248
|
+
// where("column", null) → IS NULL, where("column", "!=", null) → IS NOT NULL
|
|
249
|
+
if (value === null || value === undefined) {
|
|
250
|
+
const isNot = operator === '!=' || operator === '<>' || operator === 'IS NOT';
|
|
251
|
+
|
|
252
|
+
this.#wheres.push({
|
|
253
|
+
type: isNot ? 'notNull' : 'null',
|
|
254
|
+
column: this.#validateIdentifier(column),
|
|
255
|
+
boolean: 'AND'
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
209
261
|
this.#wheres.push({
|
|
210
262
|
type: 'basic',
|
|
211
263
|
column: this.#validateIdentifier(column),
|
|
@@ -228,6 +280,18 @@ class QueryBuilder {
|
|
|
228
280
|
operator = this.#validateWhereOperator(operator);
|
|
229
281
|
value = this.#validateWhereValue(value);
|
|
230
282
|
|
|
283
|
+
if (value === null || value === undefined) {
|
|
284
|
+
const isNot = operator === '!=' || operator === '<>' || operator === 'IS NOT';
|
|
285
|
+
|
|
286
|
+
this.#wheres.push({
|
|
287
|
+
type: isNot ? 'notNull' : 'null',
|
|
288
|
+
column: this.#validateIdentifier(column),
|
|
289
|
+
boolean: 'OR'
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return this;
|
|
293
|
+
}
|
|
294
|
+
|
|
231
295
|
this.#wheres.push({
|
|
232
296
|
type: 'basic',
|
|
233
297
|
column: this.#validateIdentifier(column),
|
|
@@ -363,7 +427,7 @@ class QueryBuilder {
|
|
|
363
427
|
}
|
|
364
428
|
|
|
365
429
|
limit(value) {
|
|
366
|
-
this.#limitValue = this.#validateInteger(value);
|
|
430
|
+
this.#limitValue = this.#validateInteger(value, 100000);
|
|
367
431
|
|
|
368
432
|
return this;
|
|
369
433
|
}
|
|
@@ -453,6 +517,40 @@ class QueryBuilder {
|
|
|
453
517
|
return parseInt(result) || 0;
|
|
454
518
|
}
|
|
455
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Count distinct values of a column.
|
|
522
|
+
* @param {string} column - Column name to count distinct values of
|
|
523
|
+
* @returns {Promise<number>}
|
|
524
|
+
*
|
|
525
|
+
* @example
|
|
526
|
+
* const uniqueVisitors = await Log_View
|
|
527
|
+
* .where("created_at", ">=", "2026-01-01")
|
|
528
|
+
* .countDistinct("ip_address");
|
|
529
|
+
*/
|
|
530
|
+
async countDistinct(column) {
|
|
531
|
+
const connection = await this.#getConnection();
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
const validatedColumn = this.#validateIdentifier(column);
|
|
535
|
+
const originalSelect = this.#selectColumns;
|
|
536
|
+
this.#selectColumns = [new RawExpression(`COUNT(DISTINCT ${validatedColumn}) as count_distinct`)];
|
|
537
|
+
this.#limitValue = 1;
|
|
538
|
+
|
|
539
|
+
const sql = this.#toSql();
|
|
540
|
+
const [rows] = await connection.query(sql, this.#bindings);
|
|
541
|
+
|
|
542
|
+
this.#selectColumns = originalSelect;
|
|
543
|
+
|
|
544
|
+
return rows[0] ? parseInt(rows[0].count_distinct) || 0 : 0;
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
throw this.#sanitizeError(error);
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
this.#reset();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
456
554
|
async max(column) {
|
|
457
555
|
return await this.#aggregate('MAX', column);
|
|
458
556
|
}
|
|
@@ -474,20 +572,24 @@ class QueryBuilder {
|
|
|
474
572
|
}
|
|
475
573
|
|
|
476
574
|
async #aggregate(func, column) {
|
|
575
|
+
const connection = await this.#getConnection();
|
|
477
576
|
const alias = func.toLowerCase();
|
|
478
|
-
const originalSelect = this.#selectColumns;
|
|
479
577
|
|
|
480
578
|
try {
|
|
481
579
|
const validatedColumn = column === '*' ? '*' : this.#validateIdentifier(column);
|
|
580
|
+
const originalSelect = this.#selectColumns;
|
|
482
581
|
this.#selectColumns = [new RawExpression(`${func}(${validatedColumn}) as ${alias}`)];
|
|
582
|
+
this.#limitValue = 1;
|
|
483
583
|
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
return row ? row[alias] : null;
|
|
487
|
-
}
|
|
584
|
+
const sql = this.#toSql();
|
|
585
|
+
const [rows] = await connection.query(sql, this.#bindings);
|
|
488
586
|
|
|
489
|
-
finally {
|
|
490
587
|
this.#selectColumns = originalSelect;
|
|
588
|
+
|
|
589
|
+
return rows[0] ? rows[0][alias] : null;
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
throw this.#sanitizeError(error);
|
|
491
593
|
}
|
|
492
594
|
}
|
|
493
595
|
|
|
@@ -585,11 +687,10 @@ class QueryBuilder {
|
|
|
585
687
|
const joins = this.#compileJoins();
|
|
586
688
|
const wheres = this.#compileWheres();
|
|
587
689
|
const groups = this.#compileGroupBy();
|
|
588
|
-
const havings = this.#compileHaving();
|
|
589
690
|
const orders = this.#compileOrders();
|
|
590
691
|
const limit = this.#compileLimit();
|
|
591
692
|
|
|
592
|
-
return `SELECT ${distinct}${columns} FROM ${this.#quoteIdentifier(this.#table)}${joins}${wheres}${groups}${
|
|
693
|
+
return `SELECT ${distinct}${columns} FROM ${this.#quoteIdentifier(this.#table)}${joins}${wheres}${groups}${orders}${limit}`;
|
|
593
694
|
}
|
|
594
695
|
|
|
595
696
|
#compileJoins() {
|
|
@@ -647,19 +748,7 @@ class QueryBuilder {
|
|
|
647
748
|
return ` GROUP BY ${this.#groups.join(', ')}`;
|
|
648
749
|
}
|
|
649
750
|
|
|
650
|
-
#compileHaving() {
|
|
651
|
-
if (this.#havings.length === 0) {
|
|
652
|
-
return '';
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const compiled = this.#havings.map((having, index) => {
|
|
656
|
-
const boolean = index === 0 ? '' : ' AND ';
|
|
657
|
-
|
|
658
|
-
return `${boolean}${having.column} ${having.operator} ?`;
|
|
659
|
-
}).join('');
|
|
660
751
|
|
|
661
|
-
return ` HAVING ${compiled}`;
|
|
662
|
-
}
|
|
663
752
|
|
|
664
753
|
#compileOrders() {
|
|
665
754
|
if (this.#orders.length === 0) {
|
|
@@ -74,6 +74,38 @@ export function validateDirection(direction) {
|
|
|
74
74
|
return upper;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Validates a raw SQL expression for use in selectRaw().
|
|
79
|
+
* Blocks dangerous patterns while allowing legitimate aggregate/function expressions.
|
|
80
|
+
*
|
|
81
|
+
* Allowed: COUNT(*), AVG(col), DISTINCT, AS aliases, DATE(), CASE WHEN, math operators
|
|
82
|
+
* Blocked: Semicolons, DDL (DROP/ALTER/CREATE/TRUNCATE), DML writes (INSERT/UPDATE/DELETE),
|
|
83
|
+
* UNION, SQL comments, system functions (LOAD_FILE, INTO OUTFILE), subqueries
|
|
84
|
+
*
|
|
85
|
+
* @param {string} expression - Raw SQL expression to validate
|
|
86
|
+
* @throws {Error} If expression contains dangerous patterns
|
|
87
|
+
*/
|
|
88
|
+
export function validateRawExpression(expression) {
|
|
89
|
+
if (typeof expression !== 'string' || expression.trim() === '') {
|
|
90
|
+
throw new Error('Raw expression must be a non-empty string.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (/;/.test(expression)) {
|
|
94
|
+
throw new Error(`Dangerous raw expression: semicolons are not allowed.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (/--|#|\/\*|\*\//.test(expression)) {
|
|
98
|
+
throw new Error(`Dangerous raw expression: SQL comments are not allowed.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dangerous = /\b(DROP|ALTER|CREATE|TRUNCATE|INSERT|UPDATE|DELETE|REPLACE|GRANT|REVOKE|UNION|INTO\s+OUTFILE|INTO\s+DUMPFILE|LOAD_FILE|BENCHMARK|SLEEP|INFORMATION_SCHEMA)\b/i;
|
|
102
|
+
|
|
103
|
+
if (dangerous.test(expression)) {
|
|
104
|
+
const match = expression.match(dangerous);
|
|
105
|
+
throw new Error(`Dangerous raw expression: "${match[0].toUpperCase()}" is not allowed in selectRaw().`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
110
|
// Private Functions
|
|
79
111
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
class Blueprint {
|
|
2
2
|
#tableName;
|
|
3
3
|
#columns = [];
|
|
4
|
+
#indexes = [];
|
|
5
|
+
#dropIndexes = [];
|
|
4
6
|
|
|
5
7
|
constructor(tableName) {
|
|
6
8
|
this.#tableName = tableName;
|
|
@@ -18,6 +20,24 @@ class Blueprint {
|
|
|
18
20
|
return this.#columns;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
getIndexes() {
|
|
24
|
+
return this.#indexes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getDropIndexes() {
|
|
28
|
+
return this.#dropIndexes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
index(columns) {
|
|
32
|
+
const cols = Array.isArray(columns) ? columns : [columns];
|
|
33
|
+
const name = `idx_${this.#tableName}_${cols.join('_')}`;
|
|
34
|
+
this.#indexes.push({ name, columns: cols });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
dropIndex(name) {
|
|
38
|
+
this.#dropIndexes.push(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
id() {
|
|
22
42
|
this.#columns.push({ name: 'id', type: 'id', modifiers: {} });
|
|
23
43
|
return this;
|
|
@@ -64,11 +84,15 @@ class Blueprint {
|
|
|
64
84
|
};
|
|
65
85
|
this.#columns.push(column);
|
|
66
86
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
const blueprint = this;
|
|
88
|
+
const chain = {
|
|
89
|
+
nullable() { column.modifiers.nullable = true; return chain; },
|
|
90
|
+
default(value) { column.modifiers.default = value; return chain; },
|
|
91
|
+
unique() { column.modifiers.unique = true; return chain; },
|
|
92
|
+
index() { blueprint.index(name); return chain; },
|
|
71
93
|
};
|
|
94
|
+
|
|
95
|
+
return chain;
|
|
72
96
|
}
|
|
73
97
|
}
|
|
74
98
|
|
|
@@ -16,6 +16,17 @@ export default class Schema {
|
|
|
16
16
|
await DB.rawQuery(this.#buildCreateSQL(blueprint, true));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
static async table(tableName, callback) {
|
|
20
|
+
const blueprint = new (await import('./Blueprint.js')).default(tableName);
|
|
21
|
+
callback(blueprint);
|
|
22
|
+
|
|
23
|
+
const statements = this.#buildAlterSQL(blueprint);
|
|
24
|
+
|
|
25
|
+
for (const sql of statements) {
|
|
26
|
+
await DB.rawQuery(sql);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
static async dropIfExists(tableName) {
|
|
20
31
|
await DB.rawQuery(`DROP TABLE IF EXISTS \`${tableName}\``);
|
|
21
32
|
}
|
|
@@ -23,13 +34,36 @@ export default class Schema {
|
|
|
23
34
|
// Private Methods
|
|
24
35
|
static #buildCreateSQL(blueprint, ifNotExists = false) {
|
|
25
36
|
const columns = blueprint.getColumns().map(col => this.#buildColumnSQL(col));
|
|
37
|
+
const indexes = blueprint.getIndexes().map(idx => this.#buildIndexSQL(idx));
|
|
38
|
+
const definitions = [...columns, ...indexes];
|
|
26
39
|
const dbConfig = Config.all('database').connections.mysql;
|
|
27
40
|
|
|
28
41
|
const charset = dbConfig.charset || 'utf8mb4';
|
|
29
42
|
const collation = dbConfig.collation || 'utf8mb4_unicode_ci';
|
|
30
43
|
const ifNotExistsClause = ifNotExists ? 'IF NOT EXISTS ' : '';
|
|
31
44
|
|
|
32
|
-
return `CREATE TABLE ${ifNotExistsClause}\`${blueprint.getTableName()}\` (\n ${
|
|
45
|
+
return `CREATE TABLE ${ifNotExistsClause}\`${blueprint.getTableName()}\` (\n ${definitions.join(',\n ')}\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static #buildAlterSQL(blueprint) {
|
|
49
|
+
const tableName = blueprint.getTableName();
|
|
50
|
+
const statements = [];
|
|
51
|
+
|
|
52
|
+
for (const idx of blueprint.getIndexes()) {
|
|
53
|
+
const cols = idx.columns.map(c => `\`${c}\``).join(', ');
|
|
54
|
+
statements.push(`ALTER TABLE \`${tableName}\` ADD INDEX \`${idx.name}\` (${cols})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const name of blueprint.getDropIndexes()) {
|
|
58
|
+
statements.push(`ALTER TABLE \`${tableName}\` DROP INDEX \`${name}\``);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return statements;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static #buildIndexSQL(idx) {
|
|
65
|
+
const cols = idx.columns.map(c => `\`${c}\``).join(', ');
|
|
66
|
+
return `INDEX \`${idx.name}\` (${cols})`;
|
|
33
67
|
}
|
|
34
68
|
|
|
35
69
|
static #buildColumnSQL(column) {
|
|
@@ -55,10 +89,18 @@ export default class Schema {
|
|
|
55
89
|
if (column.modifiers) {
|
|
56
90
|
sql += column.modifiers.nullable ? ' NULL' : ' NOT NULL';
|
|
57
91
|
if (column.modifiers.default !== null) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
92
|
+
const raw = column.modifiers.default;
|
|
93
|
+
|
|
94
|
+
if (typeof raw === 'string') {
|
|
95
|
+
const escaped = raw.replace(/'/g, "''");
|
|
96
|
+
sql += ` DEFAULT '${escaped}'`;
|
|
97
|
+
}
|
|
98
|
+
else if (typeof raw === 'boolean') {
|
|
99
|
+
sql += ` DEFAULT ${raw ? 1 : 0}`;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
sql += ` DEFAULT ${Number(raw)}`;
|
|
103
|
+
}
|
|
62
104
|
}
|
|
63
105
|
if (column.modifiers.unique) sql += ' UNIQUE';
|
|
64
106
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* AES-256-
|
|
4
|
+
* AES-256-GCM authenticated encryption and decryption utility.
|
|
5
5
|
* Uses APP_KEY from environment for secure encryption.
|
|
6
6
|
*/
|
|
7
7
|
class Encryption {
|
|
8
8
|
/**
|
|
9
|
-
* Encrypts a value using AES-256-
|
|
9
|
+
* Encrypts a value using AES-256-GCM with authentication tag.
|
|
10
10
|
* @param {string|Object} value - Value to encrypt (objects are JSON stringified)
|
|
11
|
-
* @returns {string} Encrypted string in format "iv:encryptedData"
|
|
11
|
+
* @returns {string} Encrypted string in format "iv:authTag:encryptedData"
|
|
12
12
|
*/
|
|
13
13
|
static encrypt(value) {
|
|
14
14
|
if (typeof value === "object") {
|
|
@@ -16,27 +16,35 @@ class Encryption {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const secretKey = crypto.createHash("sha256").update(process.env.APP_KEY).digest();
|
|
19
|
-
const iv = crypto.randomBytes(
|
|
20
|
-
const cipher = crypto.createCipheriv("aes-256-
|
|
19
|
+
const iv = crypto.randomBytes(12);
|
|
20
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", secretKey, iv);
|
|
21
21
|
|
|
22
22
|
let encrypted = cipher.update(value, "utf8", "hex");
|
|
23
23
|
encrypted += cipher.final("hex");
|
|
24
|
+
const authTag = cipher.getAuthTag().toString("hex");
|
|
24
25
|
|
|
25
|
-
return iv.toString("hex") + ":" + encrypted;
|
|
26
|
+
return iv.toString("hex") + ":" + authTag + ":" + encrypted;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* Decrypts an AES-256-
|
|
30
|
-
* @param {string} encryptedValue - Encrypted string in format "iv:encryptedData"
|
|
30
|
+
* Decrypts an AES-256-GCM encrypted value.
|
|
31
|
+
* @param {string} encryptedValue - Encrypted string in format "iv:authTag:encryptedData"
|
|
31
32
|
* @returns {string|false} Decrypted string or false if decryption fails
|
|
32
33
|
*/
|
|
33
34
|
static decrypt(encryptedValue) {
|
|
34
35
|
try {
|
|
35
36
|
const parts = encryptedValue.split(":");
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
if (parts.length < 3) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const iv = Buffer.from(parts[0], "hex");
|
|
43
|
+
const authTag = Buffer.from(parts[1], "hex");
|
|
44
|
+
const encryptedText = Buffer.from(parts.slice(2).join(":"), "hex");
|
|
38
45
|
const secretKey = crypto.createHash("sha256").update(process.env.APP_KEY).digest();
|
|
39
|
-
const decipher = crypto.createDecipheriv("aes-256-
|
|
46
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", secretKey, iv);
|
|
47
|
+
decipher.setAuthTag(authTag);
|
|
40
48
|
|
|
41
49
|
let decrypted = decipher.update(encryptedText, "hex", "utf8");
|
|
42
50
|
decrypted += decipher.final("utf8");
|
|
@@ -62,7 +62,9 @@ class Storage {
|
|
|
62
62
|
const base = isPrivate ? this.#privateRoot : this.#publicRoot;
|
|
63
63
|
const fullPath = this.#validatePath(base, filePath);
|
|
64
64
|
|
|
65
|
-
await fs.promises.unlink(fullPath)
|
|
65
|
+
await fs.promises.unlink(fullPath).catch(err => {
|
|
66
|
+
if (err.code !== "ENOENT") throw err;
|
|
67
|
+
});
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/**
|
package/lib/Http/Server.js
CHANGED
|
@@ -42,6 +42,11 @@ class Server {
|
|
|
42
42
|
dotenv.config({ quiet: true });
|
|
43
43
|
await Config.initialize();
|
|
44
44
|
|
|
45
|
+
if (!process.env.APP_KEY || process.env.APP_KEY.trim() === "") {
|
|
46
|
+
console.error("\x1b[31m✕ APP_KEY is not set. Please set APP_KEY in your .env file before starting the server.\x1b[0m");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
this.#config = Config.all("server");
|
|
46
51
|
const { web_server = {}, cors = {} } = this.#config;
|
|
47
52
|
|
|
@@ -278,7 +283,12 @@ class Server {
|
|
|
278
283
|
console.log("\x1b[32m✓ Server stopped gracefully\x1b[0m");
|
|
279
284
|
|
|
280
285
|
try {
|
|
281
|
-
|
|
286
|
+
const sessionManager = await SessionManager.getInstance();
|
|
287
|
+
|
|
288
|
+
await Promise.race([
|
|
289
|
+
sessionManager.close(),
|
|
290
|
+
new Promise(resolve => setTimeout(resolve, 1000))
|
|
291
|
+
]);
|
|
282
292
|
await Promise.race([
|
|
283
293
|
DB.close(),
|
|
284
294
|
new Promise(resolve => setTimeout(resolve, 1000))
|
|
@@ -335,7 +345,7 @@ class Server {
|
|
|
335
345
|
|
|
336
346
|
// Private Methods
|
|
337
347
|
static #printBanner({ success, address, host, port, error }) {
|
|
338
|
-
if (this.#config.log.channel
|
|
348
|
+
if (this.#config.log.channel === "console") {
|
|
339
349
|
return;
|
|
340
350
|
}
|
|
341
351
|
|
package/lib/Logging/Log.js
CHANGED
|
@@ -9,6 +9,7 @@ import Config from "../Core/Config.js";
|
|
|
9
9
|
class Log {
|
|
10
10
|
static #levels = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 };
|
|
11
11
|
static #levelLabels = { debug: "DEBUG", info: "INFO", warn: "WARN", error: "ERROR", fatal: "FATAL" };
|
|
12
|
+
static #dirEnsured = false;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Logs a debug message.
|
|
@@ -158,10 +159,15 @@ class Log {
|
|
|
158
159
|
/** @private */
|
|
159
160
|
static #logToFile(level, message, context, timestamp, config) {
|
|
160
161
|
const filePath = path.resolve(process.cwd(), config.file);
|
|
161
|
-
const dir = path.dirname(filePath);
|
|
162
162
|
|
|
163
|
-
if (!
|
|
164
|
-
|
|
163
|
+
if (!this.#dirEnsured) {
|
|
164
|
+
const dir = path.dirname(filePath);
|
|
165
|
+
|
|
166
|
+
if (!fs.existsSync(dir)) {
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.#dirEnsured = true;
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
const time = this.#formatTimestamp(timestamp);
|
package/lib/Route/Router.js
CHANGED
|
@@ -54,11 +54,25 @@ class HotReloadRegistry {
|
|
|
54
54
|
if (!info) return handler;
|
|
55
55
|
|
|
56
56
|
const { filePath, methodName } = info;
|
|
57
|
+
let cachedModule = null;
|
|
58
|
+
let cachedMtime = 0;
|
|
57
59
|
|
|
58
60
|
return async (request, response, param) => {
|
|
59
|
-
|
|
61
|
+
let mtime = 0;
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
try {
|
|
64
|
+
mtime = fs.statSync(filePath).mtimeMs;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
mtime = Date.now();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!cachedModule || mtime !== cachedMtime) {
|
|
71
|
+
cachedModule = await import(`file://${filePath}?t=${mtime}`);
|
|
72
|
+
cachedMtime = mtime;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return cachedModule.default[methodName](request, response, param);
|
|
62
76
|
};
|
|
63
77
|
}
|
|
64
78
|
|
|
@@ -391,25 +405,30 @@ class RouteGroup {
|
|
|
391
405
|
|
|
392
406
|
group(callback) {
|
|
393
407
|
const beforeCount = this.routes.length;
|
|
394
|
-
callback();
|
|
395
|
-
const newRoutes = this.routes.slice(beforeCount);
|
|
396
|
-
|
|
397
|
-
for (const route of newRoutes) {
|
|
398
|
-
// Apply prefix
|
|
399
|
-
if (this.options.prefix) {
|
|
400
|
-
route.url = route.url === "/"
|
|
401
|
-
? this.options.prefix
|
|
402
|
-
: this.options.prefix + route.url;
|
|
403
|
-
}
|
|
404
408
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
409
|
+
const applyGroup = () => {
|
|
410
|
+
const newRoutes = this.routes.slice(beforeCount);
|
|
411
|
+
for (const route of newRoutes) {
|
|
412
|
+
if (this.options.prefix) {
|
|
413
|
+
route.url = route.url === "/"
|
|
414
|
+
? this.options.prefix
|
|
415
|
+
: this.options.prefix + route.url;
|
|
416
|
+
}
|
|
417
|
+
if (route.name && this.options.name) {
|
|
418
|
+
route.name = this.options.name + route.name;
|
|
419
|
+
}
|
|
420
|
+
route.middlewares.unshift(...this.options.middlewares);
|
|
408
421
|
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const result = callback();
|
|
409
425
|
|
|
410
|
-
|
|
411
|
-
|
|
426
|
+
// Sync callback: apply immediately. Async callback: apply after resolution.
|
|
427
|
+
if (result && typeof result.then === "function") {
|
|
428
|
+
return result.then(() => applyGroup());
|
|
412
429
|
}
|
|
430
|
+
|
|
431
|
+
applyGroup();
|
|
413
432
|
}
|
|
414
433
|
}
|
|
415
434
|
|
package/lib/Session/Manager.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from "crypto";
|
|
|
2
2
|
import Config from "../Core/Config.js";
|
|
3
3
|
import Memory from "./Memory.js";
|
|
4
4
|
import File from "./File.js";
|
|
5
|
+
import RedisStore from "./Redis.js";
|
|
5
6
|
import Session from "./Session.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -19,13 +20,24 @@ class SessionManager {
|
|
|
19
20
|
* Gets the singleton instance.
|
|
20
21
|
* @returns {Promise<SessionManager>}
|
|
21
22
|
*/
|
|
23
|
+
static #initPromise = null;
|
|
24
|
+
|
|
22
25
|
static async getInstance() {
|
|
23
|
-
if (
|
|
24
|
-
this.#instance
|
|
25
|
-
|
|
26
|
+
if (this.#instance) {
|
|
27
|
+
return this.#instance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!this.#initPromise) {
|
|
31
|
+
this.#initPromise = (async () => {
|
|
32
|
+
const manager = new SessionManager();
|
|
33
|
+
await manager.#initialize();
|
|
34
|
+
this.#instance = manager;
|
|
35
|
+
|
|
36
|
+
return manager;
|
|
37
|
+
})();
|
|
26
38
|
}
|
|
27
39
|
|
|
28
|
-
return this.#
|
|
40
|
+
return this.#initPromise;
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
/** @private */
|
|
@@ -33,7 +45,26 @@ class SessionManager {
|
|
|
33
45
|
this.#config = Config.all("session");
|
|
34
46
|
this.#config.cookie.signed = true;
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
switch (this.#config.driver) {
|
|
49
|
+
case "file":
|
|
50
|
+
this.#store = new File();
|
|
51
|
+
break;
|
|
52
|
+
|
|
53
|
+
case "memory":
|
|
54
|
+
this.#store = new Memory();
|
|
55
|
+
break;
|
|
56
|
+
|
|
57
|
+
case "redis":
|
|
58
|
+
this.#store = new RedisStore(this.#config.lifetime);
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case "none":
|
|
62
|
+
this.#store = null;
|
|
63
|
+
return;
|
|
64
|
+
|
|
65
|
+
default:
|
|
66
|
+
throw new Error(`[Session] Unknown driver: "${this.#config.driver}"`);
|
|
67
|
+
}
|
|
37
68
|
|
|
38
69
|
if (this.#store.ready) {
|
|
39
70
|
await this.#store.ready;
|
|
@@ -171,6 +202,17 @@ class SessionManager {
|
|
|
171
202
|
}
|
|
172
203
|
}
|
|
173
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Closes the session store connection.
|
|
207
|
+
*/
|
|
208
|
+
async close() {
|
|
209
|
+
this.stopGC();
|
|
210
|
+
|
|
211
|
+
if (this.#store && typeof this.#store.close === "function") {
|
|
212
|
+
await this.#store.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
174
216
|
/**
|
|
175
217
|
* Sets up session middleware for Fastify.
|
|
176
218
|
* @param {import("fastify").FastifyInstance} server
|
|
@@ -178,9 +220,19 @@ class SessionManager {
|
|
|
178
220
|
static async setup(server) {
|
|
179
221
|
const manager = await SessionManager.getInstance();
|
|
180
222
|
|
|
223
|
+
if (!manager.#store) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
181
227
|
server.decorateRequest("session", null);
|
|
182
228
|
|
|
183
229
|
server.addHook("preHandler", async (request, response) => {
|
|
230
|
+
const lastSegment = request.url.split("/").pop().split("?")[0];
|
|
231
|
+
|
|
232
|
+
if (lastSegment.includes(".")) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
184
236
|
request.session = await manager.load(request, response);
|
|
185
237
|
});
|
|
186
238
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createClient } from "redis";
|
|
2
|
+
import Log from "../Logging/Log.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redis-based session storage.
|
|
6
|
+
* Stores sessions as JSON strings with automatic TTL expiry.
|
|
7
|
+
*/
|
|
8
|
+
class RedisStore {
|
|
9
|
+
#client;
|
|
10
|
+
#prefix;
|
|
11
|
+
#ttl;
|
|
12
|
+
ready;
|
|
13
|
+
|
|
14
|
+
constructor(lifetime) {
|
|
15
|
+
this.#prefix = (process.env.APP_NAME || "app") + ":session:";
|
|
16
|
+
this.#ttl = Math.ceil(lifetime / 1000);
|
|
17
|
+
this.ready = this.#connect();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Gets session data by ID.
|
|
22
|
+
* @param {string} id
|
|
23
|
+
* @returns {Promise<Object|null>}
|
|
24
|
+
*/
|
|
25
|
+
async get(id) {
|
|
26
|
+
const data = await this.#client.get(this.#prefix + id);
|
|
27
|
+
|
|
28
|
+
if (!data) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(data);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
await this.delete(id);
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Stores session data with automatic TTL.
|
|
44
|
+
* @param {string} id
|
|
45
|
+
* @param {Object} value
|
|
46
|
+
*/
|
|
47
|
+
async set(id, value) {
|
|
48
|
+
await this.#client.set(this.#prefix + id, JSON.stringify(value), {
|
|
49
|
+
EX: this.#ttl
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Deletes a session.
|
|
55
|
+
* @param {string} id
|
|
56
|
+
*/
|
|
57
|
+
async delete(id) {
|
|
58
|
+
await this.#client.del(this.#prefix + id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Garbage collection — no-op for Redis.
|
|
63
|
+
* Redis handles expiry automatically via TTL.
|
|
64
|
+
* @returns {Promise<number>}
|
|
65
|
+
*/
|
|
66
|
+
async gc() {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Closes the Redis connection gracefully.
|
|
72
|
+
*/
|
|
73
|
+
async close() {
|
|
74
|
+
if (this.#client) {
|
|
75
|
+
await this.#client.quit();
|
|
76
|
+
this.#client = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @private */
|
|
81
|
+
async #connect() {
|
|
82
|
+
const host = process.env.REDIS_HOST || "127.0.0.1";
|
|
83
|
+
const port = Number(process.env.REDIS_PORT) || 6379;
|
|
84
|
+
const password = process.env.REDIS_PASSWORD || undefined;
|
|
85
|
+
|
|
86
|
+
this.#client = createClient({
|
|
87
|
+
socket: {
|
|
88
|
+
host,
|
|
89
|
+
port,
|
|
90
|
+
reconnectStrategy: (retries) => {
|
|
91
|
+
if (retries > 10) {
|
|
92
|
+
console.error("\x1b[31m✕ [Session Redis] Connection lost after 10 retries. Shutting down.\x1b[0m");
|
|
93
|
+
Log.fatal("Redis connection lost permanently", { retries });
|
|
94
|
+
process.emit("SIGTERM");
|
|
95
|
+
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Math.min(retries * 100, 5000);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
password
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.#client.on("error", (err) => {
|
|
106
|
+
console.error(`\x1b[31m✕ [Session Redis] ${err.message}\x1b[0m`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
this.#client.on("ready", () => {
|
|
110
|
+
console.log("\x1b[32m✓ [Session Redis] Connected\x1b[0m");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await this.#client.connect();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default RedisStore;
|
package/lib/View/View.js
CHANGED
|
@@ -175,23 +175,24 @@ class View {
|
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const url = req.query.url;
|
|
181
|
-
if (!url) {
|
|
182
|
-
return res.code(400).send({ error: "Missing url" });
|
|
183
|
-
}
|
|
178
|
+
server.get("/__nitron/navigate", async (req, res) => {
|
|
179
|
+
const url = req.query.url;
|
|
184
180
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return res
|
|
190
|
-
.code(result.status || 200)
|
|
191
|
-
.header("X-Content-Type-Options", "nosniff")
|
|
192
|
-
.send(result);
|
|
193
|
-
});
|
|
181
|
+
if (!url) {
|
|
182
|
+
return res.code(400).send({ error: "Missing url" });
|
|
183
|
+
}
|
|
194
184
|
|
|
185
|
+
const result = await View.#renderPartial(url, req, res);
|
|
186
|
+
|
|
187
|
+
if (result.handled) return;
|
|
188
|
+
|
|
189
|
+
return res
|
|
190
|
+
.code(result.status || 200)
|
|
191
|
+
.header("X-Content-Type-Options", "nosniff")
|
|
192
|
+
.send(result);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (View.#isDev) {
|
|
195
196
|
server.get("/__nitron/icon.png", async (req, res) => {
|
|
196
197
|
const iconPath = path.join(__dirname, "Client", "nitronjs-icon.png");
|
|
197
198
|
const iconBuffer = readFileSync(iconPath);
|
package/lib/index.d.ts
CHANGED
|
@@ -56,40 +56,84 @@ export class Model {
|
|
|
56
56
|
static hidden: string[];
|
|
57
57
|
static casts: Record<string, string>;
|
|
58
58
|
|
|
59
|
+
// Direct static methods
|
|
59
60
|
static get<T extends Model>(this: new () => T): Promise<T[]>;
|
|
60
61
|
static find<T extends Model>(this: new () => T, id: number | string): Promise<T | null>;
|
|
61
62
|
static first<T extends Model>(this: new () => T): Promise<T | null>;
|
|
62
|
-
static where<T extends Model>(this: new () => T, column: string,
|
|
63
|
+
static where<T extends Model>(this: new () => T, column: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
64
|
+
static where<T extends Model>(this: new () => T, column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
65
|
+
static where<T extends Model>(this: new () => T, column: Record<string, any>): QueryBuilder<T>;
|
|
63
66
|
static select<T extends Model>(this: new () => T, ...columns: string[]): QueryBuilder<T>;
|
|
64
67
|
static orderBy<T extends Model>(this: new () => T, column: string, direction?: 'asc' | 'desc'): QueryBuilder<T>;
|
|
65
68
|
static limit<T extends Model>(this: new () => T, count: number): QueryBuilder<T>;
|
|
66
|
-
static create<T extends Model>(this: new () => T, data: Record<string, any>): Promise<T>;
|
|
67
69
|
|
|
70
|
+
// Forwarded from QueryBuilder (QUERY_METHODS)
|
|
71
|
+
static selectRaw<T extends Model>(this: new () => T, expression: string): QueryBuilder<T>;
|
|
72
|
+
static distinct<T extends Model>(this: new () => T): QueryBuilder<T>;
|
|
73
|
+
static orWhere<T extends Model>(this: new () => T, column: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
74
|
+
static orWhere<T extends Model>(this: new () => T, column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
75
|
+
static whereIn<T extends Model>(this: new () => T, column: string, values: any[]): QueryBuilder<T>;
|
|
76
|
+
static whereNotIn<T extends Model>(this: new () => T, column: string, values: any[]): QueryBuilder<T>;
|
|
77
|
+
static whereBetween<T extends Model>(this: new () => T, column: string, range: [any, any]): QueryBuilder<T>;
|
|
78
|
+
static whereNot<T extends Model>(this: new () => T, column: string, value: any): QueryBuilder<T>;
|
|
79
|
+
static join<T extends Model>(this: new () => T, table: string, col1: string, operator: string, col2: string): QueryBuilder<T>;
|
|
80
|
+
static groupBy<T extends Model>(this: new () => T, ...columns: string[]): QueryBuilder<T>;
|
|
81
|
+
static offset<T extends Model>(this: new () => T, count: number): QueryBuilder<T>;
|
|
82
|
+
static count<T extends Model>(this: new () => T, column?: string): Promise<number>;
|
|
83
|
+
static countDistinct<T extends Model>(this: new () => T, column: string): Promise<number>;
|
|
84
|
+
static max<T extends Model>(this: new () => T, column: string): Promise<number | null>;
|
|
85
|
+
static min<T extends Model>(this: new () => T, column: string): Promise<number | null>;
|
|
86
|
+
static sum<T extends Model>(this: new () => T, column: string): Promise<number>;
|
|
87
|
+
static avg<T extends Model>(this: new () => T, column: string): Promise<number | null>;
|
|
88
|
+
|
|
89
|
+
// Instance methods
|
|
68
90
|
save(): Promise<this>;
|
|
69
91
|
delete(): Promise<boolean>;
|
|
70
|
-
|
|
71
|
-
refresh(): Promise<this>;
|
|
72
|
-
toJSON(): Record<string, any>;
|
|
92
|
+
toObject(): Record<string, any>;
|
|
73
93
|
|
|
74
94
|
[key: string]: any;
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
export interface QueryBuilder<T> {
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
// Where clauses
|
|
99
|
+
where(column: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
100
|
+
where(column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
101
|
+
where(column: Record<string, any>): QueryBuilder<T>;
|
|
102
|
+
orWhere(column: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
103
|
+
orWhere(column: string, operator: string, value: string | number | boolean | null): QueryBuilder<T>;
|
|
80
104
|
whereIn(column: string, values: any[]): QueryBuilder<T>;
|
|
81
105
|
whereNotIn(column: string, values: any[]): QueryBuilder<T>;
|
|
82
|
-
|
|
83
|
-
whereNotNull(column: string): QueryBuilder<T>;
|
|
106
|
+
whereNot(column: string, value: any): QueryBuilder<T>;
|
|
84
107
|
whereBetween(column: string, range: [any, any]): QueryBuilder<T>;
|
|
108
|
+
|
|
109
|
+
// Selection
|
|
85
110
|
select(...columns: string[]): QueryBuilder<T>;
|
|
111
|
+
selectRaw(expression: string): QueryBuilder<T>;
|
|
112
|
+
distinct(): QueryBuilder<T>;
|
|
113
|
+
|
|
114
|
+
// Joins
|
|
115
|
+
join(table: string, col1: string, operator: string, col2: string): QueryBuilder<T>;
|
|
116
|
+
|
|
117
|
+
// Ordering & Grouping
|
|
86
118
|
orderBy(column: string, direction?: 'asc' | 'desc'): QueryBuilder<T>;
|
|
119
|
+
groupBy(...columns: string[]): QueryBuilder<T>;
|
|
87
120
|
limit(count: number): QueryBuilder<T>;
|
|
88
121
|
offset(count: number): QueryBuilder<T>;
|
|
122
|
+
|
|
123
|
+
// Terminal — fetch
|
|
89
124
|
get(): Promise<T[]>;
|
|
90
125
|
first(): Promise<T | null>;
|
|
91
|
-
|
|
92
|
-
|
|
126
|
+
|
|
127
|
+
// Terminal — aggregates
|
|
128
|
+
count(column?: string): Promise<number>;
|
|
129
|
+
countDistinct(column: string): Promise<number>;
|
|
130
|
+
max(column: string): Promise<number | null>;
|
|
131
|
+
min(column: string): Promise<number | null>;
|
|
132
|
+
sum(column: string): Promise<number>;
|
|
133
|
+
avg(column: string): Promise<number | null>;
|
|
134
|
+
|
|
135
|
+
// Terminal — mutation
|
|
136
|
+
insert(data: Record<string, any>): Promise<number>;
|
|
93
137
|
update(data: Record<string, any>): Promise<number>;
|
|
94
138
|
delete(): Promise<number>;
|
|
95
139
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitronjs/framework",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"description": "NitronJS is a modern and extensible Node.js MVC framework built on Fastify. It focuses on clean architecture, modular structure, and developer productivity, offering built-in routing, middleware, configuration management, CLI tooling, and native React integration for scalable full-stack applications.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"njs": "./cli/njs.js"
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"react": "^19.2.3",
|
|
37
37
|
"react-dom": "^19.2.3",
|
|
38
38
|
"react-refresh": "^0.18.0",
|
|
39
|
+
"redis": "^5.6.0",
|
|
39
40
|
"socket.io": "^4.8.1",
|
|
40
41
|
"tailwindcss": "^4.1.18",
|
|
41
42
|
"typescript": "^5.9.3"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Config } from "@nitronjs/framework";
|
|
2
2
|
|
|
3
3
|
class VerifyCsrf {
|
|
4
4
|
static async handler(req, res) {
|
|
5
|
-
const csrf =
|
|
5
|
+
const csrf = Config.all("session").csrf;
|
|
6
6
|
const token = req.body?.[csrf.tokenField] || req.headers[csrf.headerField];
|
|
7
7
|
|
|
8
8
|
if (!token) {
|