@nitronjs/framework 0.2.3 → 0.2.5
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/README.md +3 -1
- package/cli/create.js +88 -72
- package/cli/njs.js +14 -7
- package/lib/Auth/Auth.js +167 -0
- package/lib/Build/CssBuilder.js +9 -0
- package/lib/Build/FileAnalyzer.js +16 -0
- package/lib/Build/HydrationBuilder.js +17 -0
- package/lib/Build/Manager.js +15 -0
- package/lib/Build/colors.js +4 -0
- package/lib/Build/plugins.js +84 -20
- package/lib/Console/Commands/DevCommand.js +13 -9
- package/lib/Console/Commands/MakeCommand.js +24 -10
- package/lib/Console/Commands/MigrateCommand.js +0 -1
- package/lib/Console/Commands/MigrateFreshCommand.js +17 -25
- package/lib/Console/Commands/MigrateRollbackCommand.js +6 -3
- package/lib/Console/Commands/MigrateStatusCommand.js +6 -3
- package/lib/Console/Commands/SeedCommand.js +4 -2
- package/lib/Console/Commands/StorageLinkCommand.js +20 -5
- package/lib/Console/Output.js +142 -0
- package/lib/Core/Config.js +2 -1
- package/lib/Core/Paths.js +8 -0
- package/lib/Database/DB.js +141 -51
- package/lib/Database/Drivers/MySQLDriver.js +102 -157
- package/lib/Database/Migration/Checksum.js +3 -8
- package/lib/Database/Migration/MigrationRepository.js +25 -35
- package/lib/Database/Migration/MigrationRunner.js +56 -61
- package/lib/Database/Model.js +157 -83
- package/lib/Database/QueryBuilder.js +31 -0
- package/lib/Database/QueryValidation.js +36 -44
- package/lib/Database/Schema/Blueprint.js +25 -36
- package/lib/Database/Schema/Manager.js +31 -68
- package/lib/Database/Seeder/SeederRunner.js +12 -31
- package/lib/Date/DateTime.js +9 -0
- package/lib/Encryption/Encryption.js +52 -0
- package/lib/Faker/Faker.js +11 -0
- package/lib/Filesystem/Storage.js +120 -0
- package/lib/HMR/Server.js +81 -10
- package/lib/Hashing/Hash.js +41 -0
- package/lib/Http/Server.js +177 -152
- package/lib/Logging/{Manager.js → Log.js} +68 -80
- package/lib/Mail/Mail.js +187 -0
- package/lib/Route/Router.js +416 -0
- package/lib/Session/File.js +135 -233
- package/lib/Session/Manager.js +117 -171
- package/lib/Session/Memory.js +28 -38
- package/lib/Session/Session.js +71 -107
- package/lib/Support/Str.js +103 -0
- package/lib/Translation/Lang.js +54 -0
- package/lib/View/Client/hmr-client.js +94 -51
- package/lib/View/Client/nitronjs-icon.png +0 -0
- package/lib/View/{Manager.js → View.js} +44 -29
- package/lib/index.d.ts +42 -8
- package/lib/index.js +19 -12
- package/package.json +1 -1
- package/skeleton/app/Controllers/HomeController.js +7 -1
- package/skeleton/resources/css/global.css +1 -0
- package/skeleton/resources/views/Site/Home.tsx +456 -79
- package/skeleton/tsconfig.json +6 -1
- package/lib/Auth/Manager.js +0 -111
- package/lib/Database/Connection.js +0 -61
- package/lib/Database/Manager.js +0 -162
- package/lib/Encryption/Manager.js +0 -47
- package/lib/Filesystem/Manager.js +0 -74
- package/lib/Hashing/Manager.js +0 -25
- package/lib/Mail/Manager.js +0 -120
- package/lib/Route/Loader.js +0 -80
- package/lib/Route/Manager.js +0 -286
- package/lib/Translation/Manager.js +0 -49
|
@@ -1,103 +1,66 @@
|
|
|
1
|
-
import
|
|
1
|
+
import DB from "../DB.js";
|
|
2
2
|
import Config from "../../Core/Config.js";
|
|
3
3
|
|
|
4
4
|
export default class Schema {
|
|
5
5
|
|
|
6
|
+
// Public Methods
|
|
6
7
|
static async create(tableName, callback) {
|
|
7
8
|
const blueprint = new (await import('./Blueprint.js')).default(tableName);
|
|
8
9
|
callback(blueprint);
|
|
9
|
-
|
|
10
|
-
const sql = this.#buildCreateTableSQL(blueprint, false);
|
|
11
|
-
|
|
12
|
-
const connection = DatabaseManager.getInstance().connection();
|
|
13
|
-
await connection.raw(sql);
|
|
10
|
+
await DB.rawQuery(this.#buildCreateSQL(blueprint, false));
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
static async createIfNotExists(tableName, callback) {
|
|
17
14
|
const blueprint = new (await import('./Blueprint.js')).default(tableName);
|
|
18
15
|
callback(blueprint);
|
|
19
|
-
|
|
20
|
-
const sql = this.#buildCreateTableSQL(blueprint, true);
|
|
21
|
-
|
|
22
|
-
const connection = DatabaseManager.getInstance().connection();
|
|
23
|
-
await connection.raw(sql);
|
|
16
|
+
await DB.rawQuery(this.#buildCreateSQL(blueprint, true));
|
|
24
17
|
}
|
|
25
18
|
|
|
26
19
|
static async dropIfExists(tableName) {
|
|
27
|
-
|
|
28
|
-
await connection.raw(`DROP TABLE IF EXISTS \`${tableName}\``);
|
|
20
|
+
await DB.rawQuery(`DROP TABLE IF EXISTS \`${tableName}\``);
|
|
29
21
|
}
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const manager = DatabaseManager.getInstance();
|
|
36
|
-
const connection = manager.connection();
|
|
37
|
-
const connectionName = connection.getName();
|
|
38
|
-
const databaseConfig = Config.all('database');
|
|
39
|
-
const dbConfig = databaseConfig.connections[connectionName];
|
|
23
|
+
// Private Methods
|
|
24
|
+
static #buildCreateSQL(blueprint, ifNotExists = false) {
|
|
25
|
+
const columns = blueprint.getColumns().map(col => this.#buildColumnSQL(col));
|
|
26
|
+
const dbConfig = Config.all('database').connections.mysql;
|
|
40
27
|
|
|
41
28
|
const charset = dbConfig.charset || 'utf8mb4';
|
|
42
29
|
const collation = dbConfig.collation || 'utf8mb4_unicode_ci';
|
|
43
|
-
|
|
44
30
|
const ifNotExistsClause = ifNotExists ? 'IF NOT EXISTS ' : '';
|
|
45
|
-
let sql = `CREATE TABLE ${ifNotExistsClause}\`${blueprint.getTableName()}\` (\n`;
|
|
46
|
-
sql += ' ' + columnsSql.join(',\n ');
|
|
47
|
-
sql += `\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation}`;
|
|
48
31
|
|
|
49
|
-
return
|
|
32
|
+
return `CREATE TABLE ${ifNotExistsClause}\`${blueprint.getTableName()}\` (\n ${columns.join(',\n ')}\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation}`;
|
|
50
33
|
}
|
|
51
34
|
|
|
52
35
|
static #buildColumnSQL(column) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
switch (column.type) {
|
|
56
|
-
case 'id':
|
|
57
|
-
sql += 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY';
|
|
58
|
-
return sql;
|
|
59
|
-
case 'string':
|
|
60
|
-
sql += `VARCHAR(${column.length || 255})`;
|
|
61
|
-
break;
|
|
62
|
-
case 'text':
|
|
63
|
-
sql += 'TEXT';
|
|
64
|
-
break;
|
|
65
|
-
case 'integer':
|
|
66
|
-
sql += 'INT';
|
|
67
|
-
break;
|
|
68
|
-
case 'bigInteger':
|
|
69
|
-
sql += 'BIGINT';
|
|
70
|
-
break;
|
|
71
|
-
case 'boolean':
|
|
72
|
-
sql += 'TINYINT(1)';
|
|
73
|
-
break;
|
|
74
|
-
case 'timestamp':
|
|
75
|
-
sql += 'TIMESTAMP';
|
|
76
|
-
break;
|
|
77
|
-
case 'json':
|
|
78
|
-
sql += 'JSON';
|
|
79
|
-
break;
|
|
80
|
-
default:
|
|
81
|
-
throw new Error(`Unknown column type: ${column.type}`);
|
|
36
|
+
if (column.type === 'id') {
|
|
37
|
+
return `\`${column.name}\` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY`;
|
|
82
38
|
}
|
|
83
39
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
40
|
+
const typeMap = {
|
|
41
|
+
string: `VARCHAR(${column.length || 255})`,
|
|
42
|
+
text: 'TEXT',
|
|
43
|
+
integer: 'INT',
|
|
44
|
+
bigInteger: 'BIGINT',
|
|
45
|
+
boolean: 'TINYINT(1)',
|
|
46
|
+
timestamp: 'TIMESTAMP',
|
|
47
|
+
json: 'JSON'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const type = typeMap[column.type];
|
|
51
|
+
if (!type) throw new Error(`Unknown column type: ${column.type}`);
|
|
52
|
+
|
|
53
|
+
let sql = `\`${column.name}\` ${type}`;
|
|
90
54
|
|
|
55
|
+
if (column.modifiers) {
|
|
56
|
+
sql += column.modifiers.nullable ? ' NULL' : ' NOT NULL';
|
|
91
57
|
if (column.modifiers.default !== null) {
|
|
92
|
-
const
|
|
58
|
+
const val = typeof column.modifiers.default === 'string'
|
|
93
59
|
? `'${column.modifiers.default}'`
|
|
94
60
|
: column.modifiers.default;
|
|
95
|
-
sql += ` DEFAULT ${
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (column.modifiers.unique) {
|
|
99
|
-
sql += ' UNIQUE';
|
|
61
|
+
sql += ` DEFAULT ${val}`;
|
|
100
62
|
}
|
|
63
|
+
if (column.modifiers.unique) sql += ' UNIQUE';
|
|
101
64
|
}
|
|
102
65
|
|
|
103
66
|
return sql;
|
|
@@ -2,80 +2,61 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
import Paths from '../../Core/Paths.js';
|
|
5
|
-
|
|
6
|
-
const COLORS = {
|
|
7
|
-
reset: '\x1b[0m',
|
|
8
|
-
red: '\x1b[31m',
|
|
9
|
-
green: '\x1b[32m',
|
|
10
|
-
yellow: '\x1b[33m',
|
|
11
|
-
cyan: '\x1b[36m',
|
|
12
|
-
dim: '\x1b[2m',
|
|
13
|
-
bold: '\x1b[1m'
|
|
14
|
-
};
|
|
5
|
+
import Output from '../../Console/Output.js';
|
|
15
6
|
|
|
16
7
|
class SeederRunner {
|
|
17
|
-
|
|
18
8
|
static async run(seederName = null) {
|
|
19
9
|
const seedersDir = Paths.seeders;
|
|
20
10
|
|
|
21
11
|
if (!fs.existsSync(seedersDir)) {
|
|
22
|
-
|
|
12
|
+
Output.warn("No seeders directory found");
|
|
23
13
|
return { success: true, ran: [] };
|
|
24
14
|
}
|
|
25
15
|
|
|
26
|
-
let files = fs.readdirSync(seedersDir)
|
|
27
|
-
.filter(f => f.endsWith('.js'))
|
|
28
|
-
.sort();
|
|
16
|
+
let files = fs.readdirSync(seedersDir).filter(f => f.endsWith('.js')).sort();
|
|
29
17
|
|
|
30
18
|
if (files.length === 0) {
|
|
31
|
-
|
|
19
|
+
Output.warn("No seeder files found");
|
|
32
20
|
return { success: true, ran: [] };
|
|
33
21
|
}
|
|
34
22
|
|
|
35
|
-
// If specific seeder name provided, filter to only that one
|
|
36
23
|
if (seederName) {
|
|
37
24
|
const targetFile = seederName.endsWith('.js') ? seederName : `${seederName}.js`;
|
|
38
25
|
files = files.filter(f => f === targetFile);
|
|
39
|
-
|
|
40
26
|
if (files.length === 0) {
|
|
41
|
-
|
|
27
|
+
Output.error(`Seeder not found: ${seederName}`);
|
|
42
28
|
return { success: false, ran: [], error: new Error(`Seeder not found: ${seederName}`) };
|
|
43
29
|
}
|
|
44
30
|
}
|
|
45
31
|
|
|
46
|
-
|
|
32
|
+
Output.seederHeader();
|
|
47
33
|
|
|
48
34
|
const executed = [];
|
|
49
35
|
|
|
50
36
|
try {
|
|
51
37
|
for (const file of files) {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.log(`${COLORS.dim}Seeding:${COLORS.reset} ${COLORS.cyan}${file}${COLORS.reset}`);
|
|
38
|
+
const fileUrl = pathToFileURL(path.join(seedersDir, file)).href;
|
|
39
|
+
Output.pending("Seeding", file);
|
|
56
40
|
|
|
57
41
|
const { default: seeder } = await import(`${fileUrl}?t=${Date.now()}`);
|
|
58
|
-
|
|
59
42
|
if (typeof seeder.run !== 'function') {
|
|
60
43
|
throw new Error(`Seeder ${file} does not have a run() method`);
|
|
61
44
|
}
|
|
62
45
|
|
|
63
46
|
await seeder.run();
|
|
64
47
|
executed.push(file);
|
|
65
|
-
|
|
66
|
-
console.log(`${COLORS.green}✅ Seeded:${COLORS.reset} ${COLORS.cyan}${file}${COLORS.reset}\n`);
|
|
48
|
+
Output.done("Seeded", file);
|
|
67
49
|
}
|
|
68
50
|
|
|
69
|
-
|
|
51
|
+
Output.seederSuccess();
|
|
70
52
|
return { success: true, ran: executed };
|
|
71
|
-
|
|
72
53
|
}
|
|
73
54
|
catch (error) {
|
|
74
|
-
|
|
55
|
+
Output.newline();
|
|
56
|
+
Output.error(`Seeding failed: ${error.message}`);
|
|
75
57
|
return { success: false, ran: executed, error };
|
|
76
58
|
}
|
|
77
59
|
}
|
|
78
|
-
|
|
79
60
|
}
|
|
80
61
|
|
|
81
62
|
export default SeederRunner;
|
package/lib/Date/DateTime.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import locale from './Locale.js';
|
|
2
2
|
import Config from '../Core/Config.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Date and time utility with timezone and localization support.
|
|
6
|
+
* Uses config/app.js timezone and locale settings.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* DateTime.toSQL(); // "2025-01-15 10:30:00"
|
|
10
|
+
* DateTime.addDays(7); // 7 days from now
|
|
11
|
+
* DateTime.diffForHumans(ts); // "2 hours ago"
|
|
12
|
+
*/
|
|
4
13
|
class DateTime {
|
|
5
14
|
static #getDate(date = null) {
|
|
6
15
|
const timezone = Config.get('app.timezone', 'UTC');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AES-256-CBC encryption and decryption utility.
|
|
5
|
+
* Uses APP_KEY from environment for secure encryption.
|
|
6
|
+
*/
|
|
7
|
+
class Encryption {
|
|
8
|
+
/**
|
|
9
|
+
* Encrypts a value using AES-256-CBC.
|
|
10
|
+
* @param {string|Object} value - Value to encrypt (objects are JSON stringified)
|
|
11
|
+
* @returns {string} Encrypted string in format "iv:encryptedData"
|
|
12
|
+
*/
|
|
13
|
+
static encrypt(value) {
|
|
14
|
+
if (typeof value === "object") {
|
|
15
|
+
value = JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const secretKey = crypto.createHash("sha256").update(process.env.APP_KEY).digest();
|
|
19
|
+
const iv = crypto.randomBytes(16);
|
|
20
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", secretKey, iv);
|
|
21
|
+
|
|
22
|
+
let encrypted = cipher.update(value, "utf8", "hex");
|
|
23
|
+
encrypted += cipher.final("hex");
|
|
24
|
+
|
|
25
|
+
return iv.toString("hex") + ":" + encrypted;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decrypts an AES-256-CBC encrypted value.
|
|
30
|
+
* @param {string} encryptedValue - Encrypted string in format "iv:encryptedData"
|
|
31
|
+
* @returns {string|false} Decrypted string or false if decryption fails
|
|
32
|
+
*/
|
|
33
|
+
static decrypt(encryptedValue) {
|
|
34
|
+
try {
|
|
35
|
+
const parts = encryptedValue.split(":");
|
|
36
|
+
const iv = Buffer.from(parts.shift(), "hex");
|
|
37
|
+
const encryptedText = Buffer.from(parts.join(":"), "hex");
|
|
38
|
+
const secretKey = crypto.createHash("sha256").update(process.env.APP_KEY).digest();
|
|
39
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", secretKey, iv);
|
|
40
|
+
|
|
41
|
+
let decrypted = decipher.update(encryptedText, "hex", "utf8");
|
|
42
|
+
decrypted += decipher.final("utf8");
|
|
43
|
+
|
|
44
|
+
return decrypted;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default Encryption;
|
package/lib/Faker/Faker.js
CHANGED
|
@@ -8,6 +8,17 @@ import * as ColorData from './Data/Color.js';
|
|
|
8
8
|
import * as DateData from './Data/Date.js';
|
|
9
9
|
import * as PhoneData from './Data/Phone.js';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Fake data generator for testing and seeding.
|
|
13
|
+
* Generates unique values by default to prevent duplicates.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { Faker } from "@nitronjs/framework";
|
|
17
|
+
*
|
|
18
|
+
* const name = Faker.fullName();
|
|
19
|
+
* const email = Faker.email();
|
|
20
|
+
* const phone = Faker.phoneNumber();
|
|
21
|
+
*/
|
|
11
22
|
class FakerClass {
|
|
12
23
|
|
|
13
24
|
#usedValues = new Map();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import Paths from "../Core/Paths.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File storage manager for public and private file operations.
|
|
7
|
+
* Includes security measures against directory traversal attacks.
|
|
8
|
+
*/
|
|
9
|
+
class Storage {
|
|
10
|
+
static #publicRoot = Paths.storagePublic;
|
|
11
|
+
static #privateRoot = Paths.storagePrivate;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads a file from storage.
|
|
15
|
+
* @param {string} filePath - Relative path to the file
|
|
16
|
+
* @param {boolean} isPrivate - Whether to read from private storage
|
|
17
|
+
* @returns {Promise<Buffer|null>} File contents or null if not found
|
|
18
|
+
*/
|
|
19
|
+
static async get(filePath, isPrivate = false) {
|
|
20
|
+
const base = isPrivate ? this.#privateRoot : this.#publicRoot;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const fullPath = this.#validatePath(base, filePath);
|
|
24
|
+
|
|
25
|
+
return await fs.promises.readFile(fullPath);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err.message.includes("directory traversal")) {
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Saves a file to storage.
|
|
38
|
+
* @param {Object} file - File object with _buf property containing file data
|
|
39
|
+
* @param {string} dir - Directory path within storage
|
|
40
|
+
* @param {string} fileName - Name for the saved file
|
|
41
|
+
* @param {boolean} isPrivate - Whether to save to private storage
|
|
42
|
+
* @returns {Promise<boolean>} True if save successful
|
|
43
|
+
*/
|
|
44
|
+
static async put(file, dir, fileName, isPrivate = false) {
|
|
45
|
+
const base = isPrivate ? this.#privateRoot : this.#publicRoot;
|
|
46
|
+
const folderPath = this.#validatePath(base, dir);
|
|
47
|
+
const fullPath = this.#validatePath(base, path.join(dir, fileName));
|
|
48
|
+
|
|
49
|
+
await fs.promises.mkdir(folderPath, { recursive: true });
|
|
50
|
+
await fs.promises.writeFile(fullPath, file._buf);
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deletes a file from storage.
|
|
57
|
+
* @param {string} filePath - Relative path to the file
|
|
58
|
+
* @param {boolean} isPrivate - Whether to delete from private storage
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
static async delete(filePath, isPrivate = false) {
|
|
62
|
+
const base = isPrivate ? this.#privateRoot : this.#publicRoot;
|
|
63
|
+
const fullPath = this.#validatePath(base, filePath);
|
|
64
|
+
|
|
65
|
+
await fs.promises.unlink(fullPath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks if a file exists in storage.
|
|
70
|
+
* @param {string} filePath - Relative path to the file
|
|
71
|
+
* @param {boolean} isPrivate - Whether to check private storage
|
|
72
|
+
* @returns {boolean} True if file exists
|
|
73
|
+
*/
|
|
74
|
+
static exists(filePath, isPrivate = false) {
|
|
75
|
+
const base = isPrivate ? this.#privateRoot : this.#publicRoot;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const fullPath = this.#validatePath(base, filePath);
|
|
79
|
+
|
|
80
|
+
return fs.existsSync(fullPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Gets the public URL for a file in public storage.
|
|
89
|
+
* @param {string} filePath - Relative path to the file
|
|
90
|
+
* @returns {string} Public URL path
|
|
91
|
+
*/
|
|
92
|
+
static url(filePath) {
|
|
93
|
+
if (filePath.startsWith("/")) {
|
|
94
|
+
filePath = filePath.substring(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return `/storage/${filePath}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validates a file path to prevent directory traversal attacks.
|
|
102
|
+
* @param {string} base - Base directory path
|
|
103
|
+
* @param {string} filePath - Relative file path to validate
|
|
104
|
+
* @returns {string} Full validated path
|
|
105
|
+
* @throws {Error} If path escapes base directory
|
|
106
|
+
* @private
|
|
107
|
+
*/
|
|
108
|
+
static #validatePath(base, filePath) {
|
|
109
|
+
const normalizedBase = path.normalize(base) + path.sep;
|
|
110
|
+
const fullPath = path.normalize(path.join(base, filePath));
|
|
111
|
+
|
|
112
|
+
if (!fullPath.startsWith(normalizedBase)) {
|
|
113
|
+
throw new Error("Invalid file path: directory traversal detected");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return fullPath;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default Storage;
|
package/lib/HMR/Server.js
CHANGED
|
@@ -1,45 +1,97 @@
|
|
|
1
1
|
import { Server as SocketServer } from "socket.io";
|
|
2
|
+
import { createRequire } from "module";
|
|
2
3
|
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* HMR (Hot Module Replacement) server for development mode.
|
|
8
|
+
* Manages WebSocket connections and broadcasts file change events to connected clients.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // In HTTP Server
|
|
12
|
+
* HMR.registerRoutes(fastify);
|
|
13
|
+
* HMR.setup(httpServer);
|
|
14
|
+
*
|
|
15
|
+
* // Emit updates
|
|
16
|
+
* HMR.emitViewUpdate("resources/views/Site/Home.tsx");
|
|
17
|
+
* HMR.emitCss("site_style.css");
|
|
18
|
+
*/
|
|
4
19
|
class HMRServer {
|
|
5
20
|
#io = null;
|
|
6
|
-
#ready = false;
|
|
7
21
|
#connections = 0;
|
|
22
|
+
#clientScript = null;
|
|
8
23
|
|
|
24
|
+
// Public Methods
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registers socket.io client script route in Fastify.
|
|
28
|
+
* Must be called before Router.setup() to ensure the route is registered.
|
|
29
|
+
* @param {import("fastify").FastifyInstance} fastify
|
|
30
|
+
*/
|
|
31
|
+
registerRoutes(fastify) {
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const socketIoDir = path.dirname(require.resolve("socket.io/package.json"));
|
|
34
|
+
|
|
35
|
+
this.#clientScript = path.join(socketIoDir, "client-dist", "socket.io.min.js");
|
|
36
|
+
|
|
37
|
+
fastify.get("/__nitron_hmr/socket.io.js", (req, reply) => {
|
|
38
|
+
if (!this.#clientScript || !fs.existsSync(this.#clientScript)) {
|
|
39
|
+
return reply.code(404).send("Socket.io client not found");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
reply.type("application/javascript").send(fs.readFileSync(this.#clientScript, "utf-8"));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initializes the WebSocket server.
|
|
48
|
+
* @param {import("http").Server} httpServer - Node.js HTTP server instance.
|
|
49
|
+
*/
|
|
9
50
|
setup(httpServer) {
|
|
10
51
|
if (this.#io) return;
|
|
11
52
|
|
|
12
53
|
this.#io = new SocketServer(httpServer, {
|
|
13
54
|
path: "/__nitron_hmr",
|
|
14
|
-
transports: ["websocket"
|
|
55
|
+
transports: ["websocket"],
|
|
15
56
|
cors: { origin: "*" },
|
|
16
57
|
pingTimeout: 60000,
|
|
17
58
|
pingInterval: 25000,
|
|
18
|
-
serveClient:
|
|
59
|
+
serveClient: false,
|
|
60
|
+
allowEIO3: true
|
|
19
61
|
});
|
|
20
62
|
|
|
21
63
|
this.#io.on("connection", (socket) => {
|
|
22
64
|
this.#connections++;
|
|
23
|
-
socket.on("disconnect", () =>
|
|
65
|
+
socket.on("disconnect", () => this.#connections--);
|
|
24
66
|
});
|
|
25
|
-
|
|
26
|
-
this.#ready = true;
|
|
27
67
|
}
|
|
28
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Whether the HMR server is ready and accepting connections.
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
29
73
|
get isReady() {
|
|
30
|
-
return this.#
|
|
74
|
+
return this.#io !== null;
|
|
31
75
|
}
|
|
32
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Number of currently connected clients.
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
33
81
|
get connectionCount() {
|
|
34
82
|
return this.#connections;
|
|
35
83
|
}
|
|
36
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Emits a view update event to trigger hot reload of a React component.
|
|
87
|
+
* @param {string} filePath - Absolute path to the changed view file.
|
|
88
|
+
*/
|
|
37
89
|
emitViewUpdate(filePath) {
|
|
38
90
|
if (!this.#io) return;
|
|
39
91
|
|
|
40
92
|
const normalized = filePath.replace(/\\/g, "/");
|
|
41
|
-
const
|
|
42
|
-
const viewPath =
|
|
93
|
+
const match = normalized.match(/resources\/views\/(.+)\.tsx$/);
|
|
94
|
+
const viewPath = match ? match[1].toLowerCase() : path.basename(filePath, ".tsx").toLowerCase();
|
|
43
95
|
|
|
44
96
|
this.#io.emit("hmr:update", {
|
|
45
97
|
type: "view",
|
|
@@ -49,24 +101,40 @@ class HMRServer {
|
|
|
49
101
|
});
|
|
50
102
|
}
|
|
51
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Emits a CSS update event to refresh stylesheets without page reload.
|
|
106
|
+
* @param {string} [filePath] - Path to the changed CSS file. If null, refreshes all CSS.
|
|
107
|
+
*/
|
|
52
108
|
emitCss(filePath) {
|
|
53
109
|
if (!this.#io) return;
|
|
110
|
+
|
|
54
111
|
this.#io.emit("hmr:css", {
|
|
55
112
|
file: filePath ? path.basename(filePath) : null,
|
|
56
113
|
timestamp: Date.now()
|
|
57
114
|
});
|
|
58
115
|
}
|
|
59
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Emits a full page reload event.
|
|
119
|
+
* @param {string} reason - Reason for the reload (shown in dev tools).
|
|
120
|
+
*/
|
|
60
121
|
emitReload(reason) {
|
|
61
122
|
if (!this.#io) return;
|
|
123
|
+
|
|
62
124
|
this.#io.emit("hmr:reload", {
|
|
63
125
|
reason,
|
|
64
126
|
timestamp: Date.now()
|
|
65
127
|
});
|
|
66
128
|
}
|
|
67
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Emits a build error event to show error overlay in browser.
|
|
132
|
+
* @param {Error|string} error - The error that occurred.
|
|
133
|
+
* @param {string} [filePath] - Path to the file that caused the error.
|
|
134
|
+
*/
|
|
68
135
|
emitError(error, filePath) {
|
|
69
136
|
if (!this.#io) return;
|
|
137
|
+
|
|
70
138
|
this.#io.emit("hmr:error", {
|
|
71
139
|
file: filePath,
|
|
72
140
|
message: String(error?.message || error),
|
|
@@ -74,12 +142,15 @@ class HMRServer {
|
|
|
74
142
|
});
|
|
75
143
|
}
|
|
76
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Closes the WebSocket server and cleans up resources.
|
|
147
|
+
*/
|
|
77
148
|
close() {
|
|
78
149
|
if (this.#io) {
|
|
79
150
|
this.#io.close();
|
|
80
151
|
this.#io = null;
|
|
81
152
|
}
|
|
82
|
-
|
|
153
|
+
|
|
83
154
|
this.#connections = 0;
|
|
84
155
|
}
|
|
85
156
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import bcrypt from "bcrypt";
|
|
2
|
+
import Config from "../Core/Config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Secure password hashing using bcrypt with APP_KEY pepper.
|
|
6
|
+
*/
|
|
7
|
+
class Hash {
|
|
8
|
+
/**
|
|
9
|
+
* Hashes a plain text password.
|
|
10
|
+
* @param {string} textField - Plain text password to hash
|
|
11
|
+
* @returns {Promise<string>} Bcrypt hashed password
|
|
12
|
+
* @throws {Error} If APP_KEY is not set
|
|
13
|
+
*/
|
|
14
|
+
static async make(textField) {
|
|
15
|
+
if (!process.env.APP_KEY) {
|
|
16
|
+
throw new Error("APP_KEY is required for hashing");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const saltRounds = Config.get("hash.salt_rounds", 10);
|
|
20
|
+
const salt = await bcrypt.genSalt(saltRounds);
|
|
21
|
+
|
|
22
|
+
return await bcrypt.hash(textField + process.env.APP_KEY, salt);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Verifies a plain text password against a hash.
|
|
27
|
+
* @param {string} textField - Plain text password to verify
|
|
28
|
+
* @param {string} hashedText - Bcrypt hash to compare against
|
|
29
|
+
* @returns {Promise<boolean>} True if password matches
|
|
30
|
+
* @throws {Error} If APP_KEY is not set
|
|
31
|
+
*/
|
|
32
|
+
static async check(textField, hashedText) {
|
|
33
|
+
if (!process.env.APP_KEY) {
|
|
34
|
+
throw new Error("APP_KEY is required for hashing");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return await bcrypt.compare(textField + process.env.APP_KEY, hashedText);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default Hash;
|