@seip/blue-bird 1.1.0 → 1.1.2
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 +25 -12
- package/AGENTS.md +97 -25
- package/README.md +80 -23
- package/core/app.js +42 -0
- package/core/cache.js +62 -30
- package/core/cli/docker.js +219 -42
- package/core/cli/init.js +92 -8
- package/core/database.js +205 -20
- package/core/hash.js +201 -0
- package/core/index.d.ts +57 -0
- package/core/upload.js +83 -57
- package/core/ws.js +18 -1
- package/docker/Dockerfile +4 -0
- package/docker/docker-compose.sqlite.yml +69 -0
- package/package.json +4 -2
package/core/database.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import crypto from "node:crypto";
|
|
2
4
|
import { getRedisClient } from "./cache.js";
|
|
3
5
|
|
|
@@ -15,14 +17,20 @@ if (
|
|
|
15
17
|
DB_TYPE = "postgres";
|
|
16
18
|
} else if (process.env.DATABASE_URL.startsWith("mysql://")) {
|
|
17
19
|
DB_TYPE = "mysql";
|
|
20
|
+
} else if (
|
|
21
|
+
process.env.DATABASE_URL.startsWith("sqlite://") ||
|
|
22
|
+
process.env.DATABASE_URL.startsWith("sqlite:")
|
|
23
|
+
) {
|
|
24
|
+
DB_TYPE = "sqlite";
|
|
18
25
|
}
|
|
19
26
|
}
|
|
20
27
|
if (!DB_TYPE) {
|
|
21
|
-
DB_TYPE = "
|
|
28
|
+
DB_TYPE = "sqlite";
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
let mysqlPromise = null;
|
|
25
32
|
let pgPromise = null;
|
|
33
|
+
let sqlitePromise = null;
|
|
26
34
|
|
|
27
35
|
if (DB_TYPE === "postgres") {
|
|
28
36
|
try {
|
|
@@ -40,23 +48,79 @@ if (DB_TYPE === "postgres") {
|
|
|
40
48
|
"[DATABASE ERROR] mysql2 package is not installed. Database wrapper is disabled.",
|
|
41
49
|
);
|
|
42
50
|
}
|
|
51
|
+
} else if (DB_TYPE === "sqlite") {
|
|
52
|
+
try {
|
|
53
|
+
sqlitePromise = await import("better-sqlite3");
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(
|
|
56
|
+
"[DATABASE ERROR] better-sqlite3 package is not installed. Database wrapper is disabled.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/**
|
|
46
|
-
* Database class wrapping mysql2 and pg with reconnection retries, connection pooling, and query caching.
|
|
62
|
+
* Database class wrapping better-sqlite3, mysql2, and pg with reconnection retries, connection pooling, and query caching.
|
|
47
63
|
*/
|
|
48
64
|
class Database {
|
|
49
65
|
/**
|
|
50
66
|
* Initializes config from DATABASE_URL or DB_* environment variables.
|
|
51
|
-
* For default Database use .env DB_HOST, DB_USER, DB_PASSWORD...
|
|
52
|
-
* @param {number} [connectionLimit=10] - Maximum number of connections in the pool.
|
|
53
|
-
* @param {number} [queueLimit=0] - Maximum number of queued connections.
|
|
54
|
-
* @param {Object} [config={}] - Additional configuration options: DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT, DB_TYPE.
|
|
67
|
+
* For default Database use .env DB_HOST, DB_USER, DB_PASSWORD... or DB_FILE for SQLite.
|
|
68
|
+
* @param {number} [connectionLimit=10] - Maximum number of connections in the pool (MySQL/Postgres).
|
|
69
|
+
* @param {number} [queueLimit=0] - Maximum number of queued connections (MySQL/Postgres).
|
|
70
|
+
* @param {Object} [config={}] - Additional configuration options: DB_FILE, DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT, DB_TYPE.
|
|
71
|
+
* @example const connection = new Database(10, 0, { DB_TYPE: "sqlite", DB_FILE: "database/blue_bird.db" });
|
|
55
72
|
* @example const connection = new Database(10, 0, { DB_HOST: "localhost", DB_USER: "root", DB_PASSWORD: "password", DB_NAME: "blue_bird", DB_PORT: 3306, DB_TYPE: "mysql" });
|
|
56
73
|
*/
|
|
57
74
|
constructor(connectionLimit = 10, queueLimit = 0, config = {}) {
|
|
58
75
|
this.pool = null;
|
|
59
|
-
this.
|
|
76
|
+
this.db = null;
|
|
77
|
+
this.type = config.DB_TYPE || DB_TYPE;
|
|
78
|
+
|
|
79
|
+
let defaultSqliteFile = process.env.DB_FILE || "database/blue_bird.db";
|
|
80
|
+
let busyTimeout = 5000;
|
|
81
|
+
let journalMode = "WAL";
|
|
82
|
+
let synchronous = "NORMAL";
|
|
83
|
+
|
|
84
|
+
if (process.env.DATABASE_URL && !process.env.DATABASE_URL.startsWith("#")) {
|
|
85
|
+
try {
|
|
86
|
+
if (
|
|
87
|
+
process.env.DATABASE_URL.startsWith("sqlite://") ||
|
|
88
|
+
process.env.DATABASE_URL.startsWith("sqlite:")
|
|
89
|
+
) {
|
|
90
|
+
const rawUrl = process.env.DATABASE_URL;
|
|
91
|
+
const cleanUrl = rawUrl.replace(/^sqlite:\/\/|^sqlite:/, "");
|
|
92
|
+
const [filePath, queryStr] = cleanUrl.split("?");
|
|
93
|
+
if (filePath) {
|
|
94
|
+
defaultSqliteFile = filePath;
|
|
95
|
+
}
|
|
96
|
+
if (queryStr) {
|
|
97
|
+
const params = new URLSearchParams(queryStr);
|
|
98
|
+
if (params.has("busy_timeout")) {
|
|
99
|
+
busyTimeout =
|
|
100
|
+
parseInt(params.get("busy_timeout"), 10) || busyTimeout;
|
|
101
|
+
}
|
|
102
|
+
if (params.has("journal_mode")) {
|
|
103
|
+
journalMode = params.get("journal_mode").toUpperCase();
|
|
104
|
+
}
|
|
105
|
+
if (params.has("synchronous")) {
|
|
106
|
+
synchronous = params.get("synchronous").toUpperCase();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(
|
|
112
|
+
"[DATABASE ERROR] Failed to parse SQLite DATABASE_URL:",
|
|
113
|
+
err.message,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.sqliteConfig = {
|
|
119
|
+
filename: config.DB_FILE || defaultSqliteFile,
|
|
120
|
+
busyTimeout: config.busyTimeout || busyTimeout,
|
|
121
|
+
journalMode: config.journalMode || journalMode,
|
|
122
|
+
synchronous: config.synchronous || synchronous,
|
|
123
|
+
};
|
|
60
124
|
|
|
61
125
|
this.config = {
|
|
62
126
|
...config,
|
|
@@ -101,13 +165,46 @@ class Database {
|
|
|
101
165
|
}
|
|
102
166
|
|
|
103
167
|
/**
|
|
104
|
-
* Creates the database connection pool
|
|
168
|
+
* Creates the database connection pool or SQLite instance with retries on failure.
|
|
105
169
|
* @param {number} [retries=3] - Number of connection attempts.
|
|
106
|
-
* @returns {Promise<boolean>} True if connection
|
|
170
|
+
* @returns {Promise<boolean>} True if connection was created.
|
|
107
171
|
*/
|
|
108
172
|
async init(retries = 3) {
|
|
109
|
-
if (!mysqlPromise && !pgPromise) return false;
|
|
110
|
-
if (this.pool) return true;
|
|
173
|
+
if (!mysqlPromise && !pgPromise && !sqlitePromise) return false;
|
|
174
|
+
if (this.pool || this.db) return true;
|
|
175
|
+
|
|
176
|
+
if (this.type === "sqlite" && sqlitePromise) {
|
|
177
|
+
try {
|
|
178
|
+
const BetterSqlite = sqlitePromise.default || sqlitePromise;
|
|
179
|
+
const dbFilePath = path.isAbsolute(this.sqliteConfig.filename)
|
|
180
|
+
? this.sqliteConfig.filename
|
|
181
|
+
: path.resolve(process.cwd(), this.sqliteConfig.filename);
|
|
182
|
+
|
|
183
|
+
const dir = path.dirname(dbFilePath);
|
|
184
|
+
if (!fs.existsSync(dir)) {
|
|
185
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.db = new BetterSqlite(dbFilePath, {
|
|
189
|
+
timeout: this.sqliteConfig.busyTimeout,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
this.db.pragma(`journal_mode = ${this.sqliteConfig.journalMode}`);
|
|
193
|
+
this.db.pragma(`synchronous = ${this.sqliteConfig.synchronous}`);
|
|
194
|
+
this.db.pragma("foreign_keys = ON");
|
|
195
|
+
this.db.pragma(`busy_timeout = ${this.sqliteConfig.busyTimeout}`);
|
|
196
|
+
this.db.pragma("temp_store = MEMORY");
|
|
197
|
+
|
|
198
|
+
return true;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error(
|
|
201
|
+
"[DATABASE ERROR] Failed to initialize SQLite database:",
|
|
202
|
+
err.message,
|
|
203
|
+
);
|
|
204
|
+
this.db = null;
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
111
208
|
|
|
112
209
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
113
210
|
try {
|
|
@@ -138,7 +235,7 @@ class Database {
|
|
|
138
235
|
|
|
139
236
|
/**
|
|
140
237
|
* Runs a SQL query with parameters and formatting options.
|
|
141
|
-
* Supports
|
|
238
|
+
* Supports SQLite, MySQL, and PostgreSQL (converting ? to $1, $2 for Postgres automatically).
|
|
142
239
|
*
|
|
143
240
|
* @param {string} sql - SQL query string.
|
|
144
241
|
* @param {Array} [params=[]] - Query parameter array.
|
|
@@ -150,8 +247,8 @@ class Database {
|
|
|
150
247
|
* @example const insert_id = await connection.query("INSERT INTO users (name, email, password) VALUES (?, ?, ?)", ["John Doe", "john@example.com", "password"]);
|
|
151
248
|
*/
|
|
152
249
|
async query(sql, params = [], options = {}) {
|
|
153
|
-
if (!mysqlPromise && !pgPromise) return false;
|
|
154
|
-
if (!this.pool) {
|
|
250
|
+
if (!mysqlPromise && !pgPromise && !sqlitePromise) return false;
|
|
251
|
+
if (!this.pool && !this.db) {
|
|
155
252
|
const initialized = await this.init();
|
|
156
253
|
if (!initialized) return false;
|
|
157
254
|
}
|
|
@@ -159,8 +256,8 @@ class Database {
|
|
|
159
256
|
const queryOptions =
|
|
160
257
|
typeof options === "string" ? { [options]: true } : options;
|
|
161
258
|
const cleanSql = sql.trim();
|
|
162
|
-
const isSelect =
|
|
163
|
-
const isInsert =
|
|
259
|
+
const isSelect = /^(select|pragma|explain)/i.test(cleanSql);
|
|
260
|
+
const isInsert = /^insert/i.test(cleanSql);
|
|
164
261
|
|
|
165
262
|
const redisClient = getRedisClient();
|
|
166
263
|
let cacheKey = null;
|
|
@@ -201,7 +298,42 @@ class Database {
|
|
|
201
298
|
}
|
|
202
299
|
|
|
203
300
|
try {
|
|
204
|
-
if (this.type === "
|
|
301
|
+
if (this.type === "sqlite" && this.db) {
|
|
302
|
+
const stmt = this.db.prepare(cleanSql);
|
|
303
|
+
|
|
304
|
+
if (isSelect) {
|
|
305
|
+
if (queryOptions.return_row) {
|
|
306
|
+
const row = stmt.get(...params);
|
|
307
|
+
const result = row || null;
|
|
308
|
+
if (cacheKey && queryOptions.cache && redisClient) {
|
|
309
|
+
await redisClient
|
|
310
|
+
.set(cacheKey, JSON.stringify(result), {
|
|
311
|
+
EX: parseInt(queryOptions.cache),
|
|
312
|
+
})
|
|
313
|
+
.catch(() => {});
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rows = stmt.all(...params);
|
|
319
|
+
if (cacheKey && queryOptions.cache && redisClient) {
|
|
320
|
+
await redisClient
|
|
321
|
+
.set(cacheKey, JSON.stringify(rows), {
|
|
322
|
+
EX: parseInt(queryOptions.cache),
|
|
323
|
+
})
|
|
324
|
+
.catch(() => {});
|
|
325
|
+
}
|
|
326
|
+
return rows;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (isInsert) {
|
|
330
|
+
const info = stmt.run(...params);
|
|
331
|
+
return Number(info.lastInsertRowid);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const info = stmt.run(...params);
|
|
335
|
+
return info.changes;
|
|
336
|
+
} else if (this.type === "postgres" && pgPromise) {
|
|
205
337
|
let paramIndex = 1;
|
|
206
338
|
const pgSql = cleanSql.replace(/\?/g, () => `$${paramIndex++}`);
|
|
207
339
|
const res = await this.pool.query(pgSql, params);
|
|
@@ -305,13 +437,48 @@ class Database {
|
|
|
305
437
|
* });
|
|
306
438
|
*/
|
|
307
439
|
async transaction(callback) {
|
|
308
|
-
if (!mysqlPromise && !pgPromise) throw new Error("[DATABASE ERROR] No database driver available.");
|
|
309
|
-
if (!this.pool) {
|
|
440
|
+
if (!mysqlPromise && !pgPromise && !sqlitePromise) throw new Error("[DATABASE ERROR] No database driver available.");
|
|
441
|
+
if (!this.pool && !this.db) {
|
|
310
442
|
const initialized = await this.init();
|
|
311
443
|
if (!initialized) throw new Error("[DATABASE ERROR] Failed to initialize database pool.");
|
|
312
444
|
}
|
|
313
445
|
|
|
314
|
-
if (this.type === "
|
|
446
|
+
if (this.type === "sqlite" && this.db) {
|
|
447
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
448
|
+
try {
|
|
449
|
+
const tx = {
|
|
450
|
+
query: async (sql, params = [], options = {}) => {
|
|
451
|
+
const queryOptions = typeof options === "string" ? { [options]: true } : options;
|
|
452
|
+
const cleanSql = sql.trim();
|
|
453
|
+
const isSelect = /^(select|pragma|explain)/i.test(cleanSql);
|
|
454
|
+
const isInsert = /^insert/i.test(cleanSql);
|
|
455
|
+
|
|
456
|
+
const stmt = this.db.prepare(cleanSql);
|
|
457
|
+
if (isSelect) {
|
|
458
|
+
if (queryOptions.return_row) {
|
|
459
|
+
const row = stmt.get(...params);
|
|
460
|
+
return row || null;
|
|
461
|
+
}
|
|
462
|
+
return stmt.all(...params);
|
|
463
|
+
}
|
|
464
|
+
if (isInsert) {
|
|
465
|
+
const info = stmt.run(...params);
|
|
466
|
+
return Number(info.lastInsertRowid);
|
|
467
|
+
}
|
|
468
|
+
const info = stmt.run(...params);
|
|
469
|
+
return info.changes;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const result = await callback(tx);
|
|
474
|
+
this.db.exec("COMMIT");
|
|
475
|
+
return result;
|
|
476
|
+
} catch (err) {
|
|
477
|
+
this.db.exec("ROLLBACK");
|
|
478
|
+
console.error("[DATABASE ERROR] Transaction rolled back:", err.message);
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
481
|
+
} else if (this.type === "postgres" && pgPromise) {
|
|
315
482
|
const client = await this.pool.connect();
|
|
316
483
|
try {
|
|
317
484
|
await client.query("BEGIN");
|
|
@@ -394,6 +561,24 @@ class Database {
|
|
|
394
561
|
async executeTransaction(callback) {
|
|
395
562
|
return this.transaction(callback);
|
|
396
563
|
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Closes the database connection pool or SQLite instance.
|
|
567
|
+
*/
|
|
568
|
+
async close() {
|
|
569
|
+
if (this.db) {
|
|
570
|
+
this.db.close();
|
|
571
|
+
this.db = null;
|
|
572
|
+
}
|
|
573
|
+
if (this.pool) {
|
|
574
|
+
if (typeof this.pool.end === "function") {
|
|
575
|
+
await this.pool.end();
|
|
576
|
+
}
|
|
577
|
+
this.pool = null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
397
580
|
}
|
|
398
581
|
|
|
399
582
|
export { Database, DB_TYPE };
|
|
583
|
+
|
|
584
|
+
|
package/core/hash.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
let bcryptModule = null;
|
|
4
|
+
try {
|
|
5
|
+
bcryptModule = (await import("bcrypt")).default || (await import("bcrypt"));
|
|
6
|
+
} catch {
|
|
7
|
+
// bcrypt not installed, scrypt native is used
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* High-performance Password Hashing class.
|
|
12
|
+
* Uses native node:crypto scrypt by default (zero npm dependencies, NIST recommended),
|
|
13
|
+
* with seamless support for bcrypt when installed or verifying bcrypt hashes.
|
|
14
|
+
*/
|
|
15
|
+
class Hash {
|
|
16
|
+
/**
|
|
17
|
+
* Hashes a plain text password using scrypt (default) or bcrypt.
|
|
18
|
+
* @param {string} password - The plain text password.
|
|
19
|
+
* @param {Object} [options={}] - Hashing options.
|
|
20
|
+
* @param {string} [options.driver="scrypt"] - Hashing driver ('scrypt' or 'bcrypt').
|
|
21
|
+
* @param {number} [options.rounds=10] - Salt rounds for bcrypt (if driver is 'bcrypt').
|
|
22
|
+
* @param {number} [options.N=16384] - CPU/memory cost parameter for scrypt.
|
|
23
|
+
* @param {number} [options.r=8] - Block size for scrypt.
|
|
24
|
+
* @param {number} [options.p=1] - Parallelization parameter for scrypt.
|
|
25
|
+
* @returns {Promise<string>} Formatted password hash string.
|
|
26
|
+
* @example
|
|
27
|
+
* const hash = await Hash.make("mySecretPassword");
|
|
28
|
+
* // Using bcrypt:
|
|
29
|
+
* const bcryptHash = await Hash.make("mySecretPassword", { driver: "bcrypt" });
|
|
30
|
+
*/
|
|
31
|
+
static async make(password, options = {}) {
|
|
32
|
+
if (typeof password !== "string" || !password) {
|
|
33
|
+
throw new Error("[HASH ERROR] Password must be a non-empty string.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const driver = (options.driver || "scrypt").toLowerCase();
|
|
37
|
+
|
|
38
|
+
if (driver === "bcrypt") {
|
|
39
|
+
if (!bcryptModule) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"[HASH ERROR] 'bcrypt' package is not installed. Run 'npm install bcrypt' or 'npx blue-bird add bcrypt', or use the default scrypt driver.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const rounds = options.rounds || 10;
|
|
45
|
+
return bcryptModule.hash(password, rounds);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Default: native scrypt with random salt
|
|
49
|
+
const N = options.N || 16384;
|
|
50
|
+
const r = options.r || 8;
|
|
51
|
+
const p = options.p || 1;
|
|
52
|
+
const keylen = 64;
|
|
53
|
+
const salt = crypto.randomBytes(16);
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
crypto.scrypt(
|
|
57
|
+
password,
|
|
58
|
+
salt,
|
|
59
|
+
keylen,
|
|
60
|
+
{ N, r, p, maxmem: 32 * 1024 * 1024 },
|
|
61
|
+
(err, derivedKey) => {
|
|
62
|
+
if (err) return reject(err);
|
|
63
|
+
const hashString = `$scrypt$N=${N},r=${r},p=${p}$${salt.toString("hex")}$${derivedKey.toString("hex")}`;
|
|
64
|
+
resolve(hashString);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Alias for make().
|
|
72
|
+
* @param {string} password - The plain text password.
|
|
73
|
+
* @param {Object} [options={}] - Options.
|
|
74
|
+
* @returns {Promise<string>}
|
|
75
|
+
*/
|
|
76
|
+
static async hash(password, options = {}) {
|
|
77
|
+
return this.make(password, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verifies a plain text password against a hash string.
|
|
82
|
+
* Automatically detects scrypt or bcrypt hash formats.
|
|
83
|
+
* Uses timing-safe comparison to protect against side-channel attacks.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} password - The plain text password to check.
|
|
86
|
+
* @param {string} hash - The stored hash string.
|
|
87
|
+
* @returns {Promise<boolean>} True if password matches, false otherwise.
|
|
88
|
+
* @example
|
|
89
|
+
* const isValid = await Hash.verify("mySecretPassword", storedHash);
|
|
90
|
+
*/
|
|
91
|
+
static async verify(password, hash) {
|
|
92
|
+
if (
|
|
93
|
+
typeof password !== "string" ||
|
|
94
|
+
!password ||
|
|
95
|
+
typeof hash !== "string" ||
|
|
96
|
+
!hash
|
|
97
|
+
) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 1. Detect bcrypt hash format ($2a$, $2b$, $2y$)
|
|
102
|
+
if (/^\$2[aby]\$\d{2}\$/.test(hash)) {
|
|
103
|
+
if (!bcryptModule) {
|
|
104
|
+
console.error(
|
|
105
|
+
"[HASH ERROR] A bcrypt hash was detected, but the 'bcrypt' package is not installed. Run 'npm install bcrypt' or 'npx blue-bird add bcrypt'.",
|
|
106
|
+
);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await bcryptModule.compare(password, hash);
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Detect native scrypt format ($scrypt$N=...,r=...,p=...$salt$hash)
|
|
117
|
+
if (hash.startsWith("$scrypt$")) {
|
|
118
|
+
const parts = hash.split("$");
|
|
119
|
+
// Format: ["", "scrypt", "N=16384,r=8,p=1", "saltHex", "hashHex"]
|
|
120
|
+
if (parts.length !== 5) return false;
|
|
121
|
+
|
|
122
|
+
const paramsStr = parts[2];
|
|
123
|
+
const saltHex = parts[3];
|
|
124
|
+
const originalHashHex = parts[4];
|
|
125
|
+
|
|
126
|
+
if (!paramsStr || !saltHex || !originalHashHex) return false;
|
|
127
|
+
|
|
128
|
+
let N = 16384,
|
|
129
|
+
r = 8,
|
|
130
|
+
p = 1;
|
|
131
|
+
paramsStr.split(",").forEach((param) => {
|
|
132
|
+
const [k, v] = param.split("=");
|
|
133
|
+
if (k === "N") N = parseInt(v, 10) || N;
|
|
134
|
+
if (k === "r") r = parseInt(v, 10) || r;
|
|
135
|
+
if (k === "p") p = parseInt(v, 10) || p;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const salt = Buffer.from(saltHex, "hex");
|
|
139
|
+
const originalHash = Buffer.from(originalHashHex, "hex");
|
|
140
|
+
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
crypto.scrypt(
|
|
143
|
+
password,
|
|
144
|
+
salt,
|
|
145
|
+
originalHash.length,
|
|
146
|
+
{ N, r, p, maxmem: 32 * 1024 * 1024 },
|
|
147
|
+
(err, derivedKey) => {
|
|
148
|
+
if (err) return resolve(false);
|
|
149
|
+
try {
|
|
150
|
+
const matches = crypto.timingSafeEqual(originalHash, derivedKey);
|
|
151
|
+
resolve(matches);
|
|
152
|
+
} catch {
|
|
153
|
+
resolve(false);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Alias for verify().
|
|
165
|
+
* @param {string} password - The plain text password.
|
|
166
|
+
* @param {string} hash - The stored hash string.
|
|
167
|
+
* @returns {Promise<boolean>}
|
|
168
|
+
*/
|
|
169
|
+
static async check(password, hash) {
|
|
170
|
+
return this.verify(password, hash);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Checks if a given hash needs to be rehashed to match updated security parameters.
|
|
175
|
+
* @param {string} hash - The stored hash string.
|
|
176
|
+
* @param {Object} [options={}] - Target options.
|
|
177
|
+
* @returns {boolean} True if the hash should be regenerated.
|
|
178
|
+
*/
|
|
179
|
+
static needsRehash(hash, options = {}) {
|
|
180
|
+
if (!hash || typeof hash !== "string") return true;
|
|
181
|
+
const targetDriver = (options.driver || "scrypt").toLowerCase();
|
|
182
|
+
|
|
183
|
+
if (targetDriver === "bcrypt") {
|
|
184
|
+
return !/^\$2[aby]\$\d{2}\$/.test(hash);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!hash.startsWith("$scrypt$")) return true;
|
|
188
|
+
|
|
189
|
+
const parts = hash.split("$");
|
|
190
|
+
if (parts.length !== 5) return true;
|
|
191
|
+
|
|
192
|
+
const paramsStr = parts[2];
|
|
193
|
+
const targetN = options.N || 16384;
|
|
194
|
+
const targetR = options.r || 8;
|
|
195
|
+
const targetP = options.p || 1;
|
|
196
|
+
|
|
197
|
+
return !paramsStr.includes(`N=${targetN},r=${targetR},p=${targetP}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default Hash;
|
package/core/index.d.ts
CHANGED
|
@@ -11,6 +11,52 @@ declare global {
|
|
|
11
11
|
*/
|
|
12
12
|
success(data?: any, message?: string, statusCode?: number): Response;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Sends a standardized HTTP 200 OK success response.
|
|
16
|
+
* @param data Data payload.
|
|
17
|
+
* @param message Success message.
|
|
18
|
+
*/
|
|
19
|
+
ok(data?: any, message?: string): Response;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sends a standardized HTTP 201 Created success response.
|
|
23
|
+
* @param data Data payload.
|
|
24
|
+
* @param message Success message.
|
|
25
|
+
*/
|
|
26
|
+
created(data?: any, message?: string): Response;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sends a standardized HTTP 400 Bad Request error response.
|
|
30
|
+
* @param message Error message.
|
|
31
|
+
* @param errors Detailed errors array or object.
|
|
32
|
+
*/
|
|
33
|
+
badRequest(message?: string, errors?: any): Response;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sends a standardized HTTP 401 Unauthorized error response.
|
|
37
|
+
* @param message Error message.
|
|
38
|
+
*/
|
|
39
|
+
unauthorized(message?: string): Response;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sends a standardized HTTP 403 Forbidden error response.
|
|
43
|
+
* @param message Error message.
|
|
44
|
+
*/
|
|
45
|
+
forbidden(message?: string): Response;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sends a standardized HTTP 404 Not Found error response.
|
|
49
|
+
* @param message Error message.
|
|
50
|
+
*/
|
|
51
|
+
notFound(message?: string): Response;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sends a standardized HTTP 500 Internal Server Error response.
|
|
55
|
+
* @param message Error message.
|
|
56
|
+
* @param errors Error details.
|
|
57
|
+
*/
|
|
58
|
+
serverError(message?: string, errors?: any): Response;
|
|
59
|
+
|
|
14
60
|
/**
|
|
15
61
|
* Sends a standardized JSON error response.
|
|
16
62
|
* @param message Error message.
|
|
@@ -100,6 +146,14 @@ export class Validator {
|
|
|
100
146
|
validate(data: Record<string, any>): { valid: boolean; errors: any[] };
|
|
101
147
|
}
|
|
102
148
|
|
|
149
|
+
export class Hash {
|
|
150
|
+
static make(password: string, options?: { driver?: "scrypt" | "bcrypt"; rounds?: number; N?: number; r?: number; p?: number }): Promise<string>;
|
|
151
|
+
static hash(password: string, options?: { driver?: "scrypt" | "bcrypt"; rounds?: number; N?: number; r?: number; p?: number }): Promise<string>;
|
|
152
|
+
static verify(password: string, hash: string): Promise<boolean>;
|
|
153
|
+
static check(password: string, hash: string): Promise<boolean>;
|
|
154
|
+
static needsRehash(hash: string, options?: { driver?: "scrypt" | "bcrypt"; N?: number; r?: number; p?: number }): boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
103
157
|
export class Auth {
|
|
104
158
|
static encrypt(payload: any, secret: string): string;
|
|
105
159
|
static decrypt(data: string, secret: string): any;
|
|
@@ -117,6 +171,7 @@ export class Cache {
|
|
|
117
171
|
static delete(keys: string | string[]): Promise<boolean>;
|
|
118
172
|
static del(keys: string | string[]): Promise<boolean>;
|
|
119
173
|
static clear(): Promise<boolean>;
|
|
174
|
+
static getMode(): string;
|
|
120
175
|
}
|
|
121
176
|
|
|
122
177
|
export function getRedisClient(): any;
|
|
@@ -132,6 +187,8 @@ export class Database {
|
|
|
132
187
|
): Promise<{ data: any[]; total: number; page: number; limit: number; totalPages: number }>;
|
|
133
188
|
transaction<T = any>(callback: (tx: { query: (sql: string, params?: any[], options?: any) => Promise<any> }) => Promise<T>): Promise<T>;
|
|
134
189
|
executeTransaction<T = any>(callback: (tx: { query: (sql: string, params?: any[], options?: any) => Promise<any> }) => Promise<T>): Promise<T>;
|
|
190
|
+
close(): Promise<void>;
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
export default App;
|
|
194
|
+
|