@fastify-core/base 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,60 +1,78 @@
1
- # Fastify Core
1
+ # @fastify-core/base
2
2
 
3
- Fastify Core là một library backend dùng cho ứng dụng Fastify, tập trung vào các thành phần thường gặp trong hệ thống API: model layer, request handling, file upload, JWT, validation, route và utility helpers.
3
+ `@fastify-core/base` là thư viện backend cốt lõi dành cho các ứng dụng Fastify / Node.js, cung cấp hệ thống module toàn diện: tầng dữ liệu MySQL (BaseModel), quản lý container Model (Models), xử lý Request & Multipart Form-data (FastRequest), quản lý File Upload (FileUpload), xác thực JWT (JWTApp), chống tấn công CSRF, bộ kiểm tra tính hợp lệ dữ liệu (Validation), định tuyến tập trung (Route), đa ngôn ngữ (I18n), tạo chữ ký HMAC (HMAC) và các tiện ích bảo mật, xử lý dữ liệu thông dụng (Utils & Common).
4
4
 
5
- Dự án này được thiết kế để dùng như một package npm cho các ứng dụng Node.js / Fastify, đặc biệt là khi bạn cần một nền tảng backend nhanh, gọn và có sẵn các helper phổ biến.
5
+ ---
6
6
 
7
- ## Tính năng
7
+ ## 🚀 Tính năng nổi bật
8
8
 
9
- - BaseModel cho truy vấn MySQL CRUD, where builder, pagination, query raw
10
- - FastRequest để đọc request, xử lý form-data, export file upload
11
- - FileUpload để upload, resize/check type, move file giữa thư mục
12
- - JWTApp để tạo và verify token
13
- - Validation để validate dữ liệu theo rule
14
- - Route để quản lý danh sách route theo module
15
- - Common utilities cho password, slug, date, random, keyword, v.v.
16
- - CSRF protection helper cho request có thay đổi state
9
+ - **BaseModel**: Lớp trừu tượng cho thao tác cơ sở dữ liệu MySQL (CRUD, Where Builder nâng cao hỗ trợ `$in`, `$like`, `$between`, `$or`, phân trang, UPSERT, quan hệ hiển thị liên kết `modifiedSync` / `getMapName`).
10
+ - **Models Container**: Quản lý Dependency Injection singleton cho toàn bộ các model trong hệ thống (`createModels`, `initModels`, `getModels`).
11
+ - **FastRequest**: Wrapper bao bọc FastifyRequest, hỗ trợ stream multipart/form-data an toàn, tự động lưu file tạm thời, trích xuất và lọc dữ liệu (sanitization) chống XSS/SQL Injection.
12
+ - **FileUpload**: Quản lý di chuyển, sao chép, xóa tập tin và kiểm tra định dạng file (ảnh, tài liệu Office, video).
13
+ - **JWTApp**: Tạo và xác thực JSON Web Token gắn liền với IP và User-Agent của Client để chống giả mạo phiên đăng nhập.
14
+ - **CSRF Protection**: Bảo vệ các request làm thay đổi trạng thái (POST, PUT, PATCH, DELETE) qua Header và Session; token được so sánh bằng `safeEqual()` chống timing attack.
15
+ - **Validation**: Bộ quy tắc kiểm tra dữ liệu chuỗi phong phú (`required`, `requiredId`, `minLen`, `rangeNum`, `email`, `phoneVn`, `password`). Validator hoàn toàn thuần, không truy vấn database. Rule được tra cứu trong allowlist tường minh nên không thể bị gọi nhầm vào method của `Object.prototype`, và có thể kiểm tra mẫu riêng của ứng dụng bằng rule `regex` (xem mục 4.1).
16
+ - **Route**: Định tuyến module hóa, tự động gắn tiền tố (prefix) và sinh nhanh 9 route CRUD chuẩn (`addGS`). Mọi route làm thay đổi trạng thái đều dùng `POST`.
17
+ - **I18n**: Hệ thống đa ngôn ngữ hỗ trợ tiếng Việt và tiếng Anh mặc định, cho phép ghi đè và mở rộng từ ứng dụng.
18
+ - **HMAC**: Tạo chữ ký điện tử HMAC-SHA256 (hỗ trợ cả Node.js `crypto` và Web Crypto API).
19
+ - **ClientError & Utils**: Quản lý lỗi phản hồi phía client và tiện ích sanitize audit log loại bỏ dữ liệu nhạy cảm.
20
+ - **Crypto**: `safeEqual()` so sánh chuỗi bí mật theo thời gian cố định (constant-time) chống timing attack.
21
+ - **Common Utilities**: Băm mật khẩu bằng Argon2, tạo slug, sinh keyword tìm kiếm, định dạng ngày tháng theo múi giờ Việt Nam, xử lý số Decimal.js độ chính xác cao, sinh chuỗi ngẫu nhiên bằng CSPRNG (`crypto.randomInt`).
17
22
 
18
- ## Yêu cầu
23
+ ---
19
24
 
20
- - Node.js >= 24
21
- - Fastify >= 5
22
- - MySQL / mysql2
23
- - TypeScript (khuyến nghị)
25
+ ## 📋 Yêu cầu hệ thống
24
26
 
25
- ## Cài đặt
27
+ - **Node.js**: `>= 24`
28
+ - **Fastify**: `>= 5.0.0`
29
+ - **MySQL**: MySQL 8.x hoặc MariaDB (thư viện `mysql2`)
30
+ - **Module System**: ESM (ECMAScript Modules)
31
+
32
+ ---
33
+
34
+ ## 📦 Cài đặt
26
35
 
27
36
  ```bash
28
37
  npm install @fastify-core/base
29
38
  ```
30
39
 
31
- ## Import
40
+ Đồng thời cài đặt các `peerDependencies` cần thiết nếu dự án chưa có:
41
+
42
+ ```bash
43
+ npm install fastify mysql2 @fastify/session argon2 date-fns date-fns-tz decimal.js jsonwebtoken dotenv
44
+ ```
45
+
46
+ `@fastify/multipart` là **peer dependency tuỳ chọn**: chỉ cần khi dùng `FastRequest` với
47
+ `multipart/form-data` (upload file). Hãy đăng ký plugin này trước khi dùng `FastRequest`:
32
48
 
33
49
  ```ts
34
- import {
35
- BaseModel,
36
- FastRequest,
37
- FileUpload,
38
- JWTApp,
39
- Route,
40
- Validation,
41
- makePassword,
42
- checkPassword,
43
- slugify,
44
- ensureCsrfProtection,
45
- } from '@fastify-core/base';
50
+ import multipart from '@fastify/multipart';
51
+ await app.register(multipart);
46
52
  ```
47
53
 
48
- ## Ví dụ sử dụng
54
+ ---
55
+
56
+ ## 📚 Hướng dẫn sử dụng chi tiết
49
57
 
50
- ### 1. Tạo model
58
+ ### 1. BaseModel & Models (Tầng cơ sở dữ liệu)
51
59
 
60
+ #### Định nghĩa Model
52
61
  ```ts
53
62
  import { BaseModel } from '@fastify-core/base';
54
63
  import type { Pool } from 'mysql2/promise';
55
64
 
56
- export class UserModel extends BaseModel<any> {
65
+ export interface UserItem {
66
+ id: number;
67
+ fullname: string;
68
+ email: string;
69
+ roleId: number;
70
+ status: number;
71
+ }
72
+
73
+ export class UserModel extends BaseModel<UserItem> {
57
74
  table = 'users';
75
+ isDeleted = true; // Bật tính năng soft delete (tự động lọc isDeleted = 0)
58
76
 
59
77
  constructor(pool: Pool) {
60
78
  super(pool);
@@ -62,168 +80,480 @@ export class UserModel extends BaseModel<any> {
62
80
  }
63
81
  ```
64
82
 
65
- ### 2. Validate dữ liệu
83
+ > `fieldName()` vẫn có sẵn để ứng dụng tự dùng (ví dụ dựng thông báo lỗi), nhưng
84
+ > `Validation` không còn gọi tới nó vì validator không phụ thuộc model.
85
+
86
+ #### Quản lý tập trung với `initModels` & `getModels`
87
+ ```ts
88
+ import mysql from 'mysql2/promise';
89
+ import { initModels, getModels } from '@fastify-core/base';
90
+ import { UserModel } from './models/UserModel.js';
91
+
92
+ const pool = mysql.createPool({ host: 'localhost', user: 'root', database: 'app_db' });
93
+
94
+ // Khởi tạo một lần duy nhất khi ứng dụng khởi động
95
+ export const appModels = initModels({
96
+ user: UserModel
97
+ }, pool);
98
+
99
+ export type AppModels = typeof appModels;
100
+
101
+ // Lấy instance model ở bất kỳ controller nào
102
+ const { user } = getModels<AppModels>();
103
+
104
+ // 1. Tìm kiếm với bộ lọc phong phú ($in, $like, $between, $or)
105
+ const users = await user.find({
106
+ status: 1,
107
+ roleId: { $in: [1, 2] },
108
+ fullname: { $like: 'Nguyen' }
109
+ }, { id: 'DESC' }, false, ['id', 'fullname', 'email'], 10);
110
+
111
+ // Khi cần chọn cột của bảng JOIN, dùng tên cột hoặc column reference an toàn:
112
+ const usersWithRoles = await user.find(
113
+ {},
114
+ { id: 'DESC' },
115
+ {
116
+ table: 'roles',
117
+ on: { left: 'users.roleId', right: 'roles.id', operator: '=' },
118
+ fields: ['name']
119
+ },
120
+ [{ column: 'users.id' }, { column: 'roles.name', alias: 'roleName' }]
121
+ );
122
+
123
+ // 2. Phân trang
124
+ const result = await user.findWithPagination(
125
+ { status: 1 },
126
+ { id: 'DESC' },
127
+ false,
128
+ false,
129
+ 1, // page
130
+ 20 // limit
131
+ );
132
+
133
+ // 3. Thêm mới hoặc cập nhật tự động (nếu item có id > 0 thực hiện UPDATE, ngược lại INSERT)
134
+ const savedUser = await user.save({ fullname: 'Nguyen Van A', email: 'a@example.com' });
135
+ ```
136
+
137
+ > ⚠️ **Lưu ý bảo mật SQL**
138
+ >
139
+ > - Tên bảng / tên cột trong `filter`, `data`, `order` được thư viện escape tự động (nhân đôi backtick), an toàn ngay cả khi key đến từ request.
140
+ > - `select` chỉ nhận tên cột / `table.column` / `table.*` hoặc `{ column, alias }`; không nhận SQL thô, hàm, aggregate hay subquery.
141
+ > - `join.on` chỉ nhận phép so sánh đơn giản giữa các cột (ví dụ `"users.id = profiles.user_id"` hoặc `{ left, right, operator }`); không nhận SQL thô. `join.type` dùng whitelist (`LEFT JOIN`, `INNER JOIN`...), sai sẽ ném `invalid_join_type`.
142
+ > - `order`: key là tên cột (hỗ trợ `alias.col`), value chỉ nhận `'ASC'` hoặc `'DESC'` — sai sẽ ném `invalid_order_direction`.
143
+ > - `group` / `having`: hiện không có tham số tương ứng trong `find` hoặc `findWithPagination`; không tự nối SQL từ request vào các mệnh đề này. Nếu cần, hãy dùng truy vấn riêng với giá trị bind.
144
+ > - Các câu SQL cần biểu thức phức tạp nên viết bằng truy vấn riêng với giá trị bind, không nối dữ liệu request vào SQL.
145
+
146
+ ---
147
+
148
+ ### 2. FastRequest (Xử lý Request & Upload Tạm)
149
+
150
+ `FastRequest` xử lý cả JSON body và `multipart/form-data`. Khi upload qua multipart, file tự động được kiểm tra và lưu tạm vào `uploads/tmb`.
151
+
152
+ ```ts
153
+ import Fastify from 'fastify';
154
+ import multipart from '@fastify/multipart';
155
+ import { FastRequest } from '@fastify-core/base';
156
+
157
+ const app = Fastify();
158
+ app.register(multipart);
159
+
160
+ app.post('/api/users', async (request, reply) => {
161
+ const req = new FastRequest(request);
162
+ await req.start(); // Bắt buộc gọi để parse body và stream file tạm
163
+
164
+ try {
165
+ // Đọc dữ liệu an toàn với các bộ lọc sanitization
166
+ const fullname = req.getPost('fullname', '', 'stripTags');
167
+ const age = req.getPost('age', 0, 'int');
168
+ const amount = req.getPost('amount', 0, 'decimal');
169
+ const birthday = req.getPost('birthday', null, 'date'); // Chuỗi ISO sang 'yyyy-MM-dd'
170
+ const userIds = req.getPost('userIds', [], 'ids'); // Mảng hoặc chuỗi JSON ID thành number[] duy nhất
171
+
172
+ const avatarFiles = req.files['avatar'];
173
+
174
+ return { fullname, age, userIds, filesCount: avatarFiles?.length };
175
+ } finally {
176
+ // Tự động xóa các file tạm trong uploads/tmb
177
+ await req.end();
178
+ }
179
+ });
180
+ ```
181
+
182
+ ---
183
+
184
+ ### 3. FileUpload (Quản lý & Lưu trữ File)
185
+
186
+ ```ts
187
+ import { FileUpload } from '@fastify-core/base';
188
+
189
+ // req.files lấy từ FastRequest.files
190
+ const uploader = new FileUpload(req.files, 'static'); // Gốc: public/static
191
+
192
+ // Kiểm tra loại file (ImgFile | OfficeFile | VideoFile)
193
+ await uploader.checkFile('avatar', 'ImgFile');
194
+
195
+ // Lưu file vào public/static/avatars
196
+ const savedPath = await uploader.uploadFile('avatar', 'avatars');
197
+ // Kết quả trả về: "avatars/1711370000000___avatar.png"
198
+
199
+ // Xóa file không còn dùng
200
+ FileUpload.removeFile(savedPath, 'static');
201
+ ```
202
+
203
+ ---
204
+
205
+ ### 4. Validation (Xác thực dữ liệu)
66
206
 
67
207
  ```ts
68
208
  import { Validation } from '@fastify-core/base';
69
209
 
70
- const payload = {
71
- email: 'admin@example.com',
72
- password: '123456',
210
+ // Validator thuần: khởi tạo không cần truyền model
211
+ const validator = new Validation();
212
+
213
+ const inputData = {
214
+ email: 'test@example.com',
215
+ password: 'Password123@',
216
+ phone: '0912345678',
217
+ age: 25
73
218
  };
74
219
 
75
220
  const rules = {
76
- email: 'required|minLen(5)',
77
- password: 'required|minLen(6)',
221
+ email: 'required|email',
222
+ password: 'required|password',
223
+ phone: 'required|phoneVn',
224
+ age: 'required|integer|rangeNum(18,60)'
78
225
  };
79
226
 
80
227
  try {
81
- await new Validation().runValidate(payload, rules);
82
- console.log('Valid');
83
- } catch (error: any) {
84
- console.error(error.message);
228
+ await validator.runValidate(inputData, rules);
229
+ console.log('Dữ liệu hợp lệ!');
230
+ } catch (error) {
231
+ console.error('Lỗi xác thực:', error instanceof Error ? error.message : String(error));
85
232
  }
86
233
  ```
87
234
 
88
- ### 3. Xử lý request
235
+ #### Đa ngôn ngữ cho thông báo lỗi
236
+
237
+ Tham số thứ 3 của `runValidate()` nhận mã ngôn ngữ (mặc định `'vi'`).
238
+ Thông báo được tra theo khóa `validate_<tênRule>` trong từ điển i18n:
89
239
 
90
240
  ```ts
91
- import { FastRequest } from '@fastify-core/base';
241
+ import { Validation, initI18n } from '@fastify-core/base';
92
242
 
93
- fastify.post('/user', async (request) => {
94
- const req = new FastRequest(request as any);
95
- await req.start();
243
+ const validator = new Validation();
244
+ const rules = { email: 'required|email', age: 'required|rangeNum(18,60)' };
245
+ const input = { email: 'sai', age: 10 };
96
246
 
97
- const name = req.getPost('name', '', 'stripTags');
98
- const age = req.getPost('age', 0, 'int');
247
+ await validator.runValidate(input, rules, 'vi');
248
+ // throw: "Email không đúng định dạng, age phải nằm trong khoản từ 18 đến 60"
99
249
 
100
- await req.end();
250
+ await validator.runValidate(input, rules, 'en');
251
+ // throw: "Email is not in the correct format, age must be between 18 and 60"
101
252
 
102
- return { name, age };
253
+ // Thêm ngôn ngữ mới hoặc ghi đè thông báo có sẵn
254
+ initI18n({
255
+ ja: {
256
+ validate_required: '{placeholder} は必須です',
257
+ validate_email: '{placeholder} の形式が不正です'
258
+ },
259
+ en: { validate_email: 'Please provide a valid {placeholder}' }
103
260
  });
104
261
  ```
105
262
 
106
- ### 4. Upload file
263
+ - Ngôn ngữ chưa có trong từ điển, hoặc thiếu key, sẽ **tự lùi về tiếng Việt** — luôn nhận được thông báo đọc được.
264
+ - `BaseModel.validate(item, rules, lang)` và `BaseModel.vdSave(item, rules, conn, lang)` cũng nhận `lang`.
265
+ - Vì `lang` là tham số tường minh nên an toàn khi Fastify xử lý nhiều request song song với ngôn ngữ khác nhau.
266
+
267
+ > **Kiểm tra trùng lặp**: rule `unique` đã được gỡ bỏ vì truy vấn database.
268
+ > Hãy tự kiểm tra ở tầng nghiệp vụ trước khi lưu:
269
+ > ```ts
270
+ > const existing = await userModel.findOne({ email: inputData.email });
271
+ > if (existing) throw new Error('Email đã được sử dụng');
272
+ > ```
273
+ >
274
+ > Ràng buộc `UNIQUE` ở tầng database vẫn nên được giữ như lớp bảo vệ cuối cùng.
275
+
276
+ ---
277
+
278
+ ### 4.1 Rule `regex` — kiểm tra mẫu tuỳ chỉnh
279
+
280
+ Ngoài 19 rule có sẵn, rule `regex` cho phép kiểm tra một mẫu riêng của ứng dụng
281
+ (mã văn bản, biển mã số, mã đơn hàng…) ngay trong chuỗi rule:
107
282
 
108
283
  ```ts
109
- import { FileUpload } from '@fastify-core/base';
284
+ import { Validation } from '@fastify-core/base';
110
285
 
111
- const files = { avatar: [/* file object */] } as any;
112
- const uploader = new FileUpload(files, 'static');
286
+ const validator = new Validation();
287
+ const rules = {
288
+ so: 'required|regex(/^0\d{9}$/)',
289
+ maVanBan: 'regex(/^[A-Z]{2}\d{6}$/)'
290
+ };
113
291
 
114
- const uploaded = await uploader.uploadFile('avatar', 'users');
115
- console.log(uploaded);
292
+ await validator.runValidate({ so: '0900000000', maVanBan: 'AB123456' }, rules); // ok
116
293
  ```
117
294
 
118
- ### 5. JWT
295
+ **Cách viết pattern**
296
+
297
+ | Cách viết | Ví dụ |
298
+ |---|---|
299
+ | Có dấu nháy bao | `regex(/^[A-Z]{2}\d{6}$/)` |
300
+ | Không có dấu nháy bao | `regex(^[A-Z]+$)` |
301
+ | Kèm cờ | `regex(/^ab+c$/i)` — cờ `g`, `y` bị bỏ qua vì gây trạng thái `lastIndex` |
302
+ | Có dấu phẩy | `regex(/a{2,3}/)`, `regex(/^[a,b]{3}$/)` — giữ nguyên, không bị tách |
303
+
304
+ **Lưu ý quan trọng**
305
+
306
+ - **Dấu `|` phải escape thành `\|`** vì `|` là dấu phân cách rule:
307
+ - ✅ `regex(/^(?:x\|y)=1$/)`
308
+ - ❌ `regex(/^(?:x|y)=1$/)` → bị tách thành 2 rule và báo `Method doesnt exists`
309
+ - Cách thay thế: dùng lớp ký tự `regex(/^[xy]=1$/)`
310
+ - **Không neo tự động** — `regex(/abc/)` cũng khớp `xxabcxx`. Hãy tự viết `^...$`.
311
+ - **Giá trị rỗng luôn hợp lệ**, đúng như các rule khác → dùng kèm `required`.
312
+ - **Tránh biểu thức có catastrophic backtracking (ReDoS)** như `/(a+)+$/`.
313
+ - Pattern hỏng hoặc thiếu sẽ bị coi là **không hợp lệ** (không ném lỗi).
314
+ - Thông báo lỗi lấy từ khoá i18n `validate_regex`:
315
+ `"{placeholder} không đúng định dạng yêu cầu"`.
316
+
317
+ ### 5. JWT & Session (JWTApp & MySQLSessionStore)
119
318
 
120
319
  ```ts
121
- import { JWTApp } from '@fastify-core/base';
320
+ import { JWTApp, MySQLSessionStore } from '@fastify-core/base';
321
+
322
+ // 1. Tạo JWT Token kèm IP và User-Agent bảo mật chống giả mạo
323
+ const token = JWTApp.createToken({ id: 1, fullname: 'Administrator' }, request);
122
324
 
123
- const token = JWTApp.createToken({ id: 1, fullname: 'Admin' }, request);
124
- const payload = JWTApp.verifyToken(request);
325
+ // 2. Xác thực JWT Token từ Header hoặc Cookie
326
+ const userPayload = JWTApp.verifyToken(request); // Header Authorization: Bearer <token>
327
+ // const userPayload = JWTApp.verifyToken(request, true); // Cookie: accessToken
328
+
329
+ // 3. MySQL Session Store dùng cho @fastify/session
330
+ const sessionStore = new MySQLSessionStore(pool);
125
331
  ```
126
332
 
127
- ### 6. CSRF
333
+ ---
334
+
335
+ ### 6. CSRF Protection
128
336
 
129
337
  ```ts
130
- import { ensureCsrfProtection } from '@fastify-core/base';
338
+ import { ensureCsrfProtection, ClientError } from '@fastify-core/base';
131
339
 
132
- const result = ensureCsrfProtection(request, session);
133
- if (!result.allowed) {
134
- throw new Error(result.reason || 'csrf_invalid');
135
- }
340
+ app.addHook('preHandler', async (request, reply) => {
341
+ const session = (request as { session?: { csrfToken?: string } }).session;
342
+ const check = ensureCsrfProtection(request, session);
343
+ if (!check.allowed) {
344
+ throw new ClientError('Mã CSRF token không hợp lệ', 403, check.reason || 'csrf_forbidden');
345
+ }
346
+ });
136
347
  ```
137
348
 
138
- ## API chính
349
+ ---
139
350
 
140
- ### BaseModel
351
+ ### 7. Định tuyến tập trung (Route)
141
352
 
142
- BaseModel hỗ trợ các method cơ bản như:
353
+ ```ts
354
+ import { Route } from '@fastify-core/base';
355
+ import { ProductController } from './controllers/ProductController.js';
143
356
 
144
- - `findOne(filter)`
145
- - `find(filter)`
146
- - `save(item, conn?)`
147
- - `update(data, filter, conn?)`
148
- - `query(sql, params?, conn?)`
149
- - `buildWhere(filter, als?)`
150
- - `getConnection()`
357
+ const route = new Route();
151
358
 
152
- ### FastRequest
359
+ // Nhóm route tùy biến với prefix
360
+ route.add('/admin/products', [
361
+ { link: '/status', module: 'product', controller: ProductController, action: 'updateStatus', method: 'post' }
362
+ ]);
153
363
 
154
- - `start()`
155
- - `end()`
156
- - `isPost()`
157
- - `isGet()`
158
- - `getPost(name, defaultValue, type)`
159
- - `getParam(name, defaultValue, type)`
160
- - `get(name, defaultValue, type)`
161
- - `getHeader(key)`
364
+ // Tự động sinh nhanh bộ 9 route CRUD chuẩn:
365
+ // GET /getList, GET /detail/:code, POST /create, POST /edit/:code, POST /copy,
366
+ // POST /import, GET /export, POST /delete/:code, POST /delete
367
+ route.addGS('/admin/products', ProductController, 'product');
368
+ ```
162
369
 
163
- ### FileUpload
370
+ ---
164
371
 
165
- - `uploadFile(fieldName, folders)`
166
- - `uploadFiles(fieldName, folders)`
167
- - `checkFile(fieldName, type)`
168
- - `copyFiles(files)`
169
- - `removeFile(fileName, uploadType)`
170
- - `removeFiles(paths, uploadType)`
372
+ ### 8. Đa ngôn ngữ (I18n)
171
373
 
172
- ### Validation
374
+ ```ts
375
+ import { t, initI18n } from '@fastify-core/base';
173
376
 
174
- Validation hỗ trợ rule như:
377
+ initI18n({
378
+ vi: { welcome: 'Chào mừng bạn!' },
379
+ en: { welcome: 'Welcome!' }
380
+ });
175
381
 
176
- - `required`
177
- - `requiredId`
178
- - `minLen(6)`
179
- - `maxLen(255)`
180
- - `equalLen(10)`
181
- - `rangeNum(1, 50)`
182
- - `min(10)`
183
- - `max(100)`
184
- - `integer`
382
+ console.log(t('invalid_403', 'vi')); // 'Truy cập không được phép'
383
+ console.log(t('welcome', 'en')); // 'Welcome!'
384
+ ```
185
385
 
186
- ## Common helpers
386
+ ---
187
387
 
188
- Một số utility có sẵn:
388
+ ### 9. HMAC Signature
189
389
 
190
- - `makePassword(value)`
191
- - `checkPassword(value, hash)`
192
- - `randomText(length)`
193
- - `removeVietnameseTones(str)`
194
- - `slugify(text)`
195
- - `generateKeywords(text)`
196
- - `parseDate(date)`
197
- - `formatDate(date, format)`
198
- - `toMySQLDateNowVN()`
199
- - `sumArrCol(items, col)`
390
+ ```ts
391
+ import { createSignature, createHmac } from '@fastify-core/base';
392
+
393
+ // Ký bằng Node.js crypto (dùng biến môi trường HMAC_SECRET)
394
+ const signatureNode = createSignature({ orderId: 100, amount: 250000 });
395
+
396
+ // Ký bằng Web Crypto API (Browser / Edge runtime)
397
+ const signatureWeb = await createHmac({ orderId: 100, amount: 250000 }, 'secret_key');
398
+ ```
200
399
 
201
- ## Route
400
+ ---
202
401
 
203
- Route giúp quản lý route tập trung và sinh CRUD nhanh:
402
+ ### 10. Tiện ích thường dùng (Common & Utils)
204
403
 
205
404
  ```ts
206
- const route = new Route();
207
- route.add('/admin/user', [
208
- { link: '/list', module: 'user', controller: UserController, action: 'index' },
209
- { link: '/detail/:id', module: 'user', controller: UserController, action: 'detail' },
210
- ]);
405
+ import {
406
+ makePassword,
407
+ checkPassword,
408
+ slugify,
409
+ randomText,
410
+ removeVietnameseTones,
411
+ generateKeywords,
412
+ toMySQLDateNowVN,
413
+ formatDate,
414
+ sumArrColumn,
415
+ decimalNumber,
416
+ sanitizeAuditPayload,
417
+ buildAuditLogData
418
+ } from '@fastify-core/base';
419
+
420
+ // Mật khẩu Argon2
421
+ const hashed = await makePassword('mySecretPass');
422
+ const isOk = await checkPassword('mySecretPass', hashed);
211
423
 
212
- route.addGS('/admin/product', ProductController, 'product');
424
+ // Chuỗi & Ký tự
425
+ const slug = slugify('Khoá học Lập trình Web'); // 'khoa-hoc-lap-trinh-web'
426
+ const code = randomText(6); // Chuỗi số ngẫu nhiên 6 chữ số
427
+
428
+ // Thời gian theo timezone Việt Nam
429
+ const nowVN = toMySQLDateNowVN(); // '2026-03-25 15:30:00'
430
+
431
+ // Audit log an toàn (tự động loại bỏ password, token, secret, api key...)
432
+ const audit = buildAuditLogData(newData, oldData, ['lastActive']);
433
+
434
+ // Chuỗi ngẫu nhiên CSPRNG (dùng cho OTP, CSRF token, session id...)
435
+ randomText(6); // '428193' — chỉ chữ số
436
+ randomText(32, true); // 32 ký tự chữ + số
437
+
438
+ // Escape HTML — biến mọi thẻ thành text, không thể bypass XSS
439
+ escapeHtml('<img src=x onerror=alert(1)>');
440
+ // => '&lt;img src=x onerror=alert(1)&gt;'
441
+
442
+ // So sánh chuỗi bí mật chống timing attack
443
+ import { safeEqual } from '@fastify-core/base';
444
+ if (safeEqual(req.headers['x-api-key'] ?? '', process.env.API_KEY ?? '')) {
445
+ // khớp
446
+ }
213
447
  ```
214
448
 
215
- ## Environment variables
449
+ ---
216
450
 
217
- Một số tính năng JWT hoặc session cần biến môi trường:
451
+ ## 🔐 Cấu hình biến môi trường (.env)
452
+
453
+ | Biến | Ý nghĩa | Mặc định |
454
+ |---|---|---|
455
+ | `JWT_KEY` | Khóa bí mật ký và giải mã JWT token | Bắt buộc |
456
+ | `JWT_SECRET_KEY` | Khóa bảo mật đối chiếu cho `JWTApp.checkSecretKey` | Bắt buộc nếu dùng |
457
+ | `JWT_ISS` | Issuer của JWT token | Tuỳ chọn |
458
+ | `JWT_AUD` | Audience của JWT token | Tuỳ chọn |
459
+ | `JWT_TIMEOUT` | Thời gian hết hạn của JWT token (giây) | `3600` |
460
+ | `HMAC_SECRET` | Khóa bí mật dùng cho hàm `createSignature` | Bắt buộc nếu dùng |
461
+
462
+ ---
463
+
464
+ ## 🧪 Testing
465
+
466
+ Thư viện đi kèm bộ test trong thư mục [`test/`](./test) gồm **unit test** và **e2e test**, chạy bằng test runner built-in của Node.js (`node --test`) nên không cần thêm dependency.
218
467
 
219
468
  ```bash
220
- JWT_SECRET_KEY=your_secret_key
221
- JWT_KEY=your_verify_token
222
- JWT_AUD=your_audience
223
- JWT_ISS=your_issuer
224
- JWT_TIMEOUT=3600
469
+ npm run test:unit # chỉ unit test
470
+ npm run test:e2e # chỉ e2e test
471
+ npm run test:node # toàn bộ test trong thư mục test/
472
+ npm test # tsc --noEmit + oxlint + sql regression + test:node
225
473
  ```
226
474
 
227
- ## Lưu ý về publish package
228
-
229
- Package hiện đang cấu hình export theo kiểu ESM, nên khi dùng trong môi trường Node/TypeScript cần đảm bảo project của bạn hỗ trợ ESM nếu import theo kiểu module.
475
+ Chi tiết về cấu trúc, quy ước và các hành vi được ghi nhận: [`test/README.md`](./test/README.md).
476
+
477
+ ---
478
+
479
+ ## 🛡️ XSS — cách dùng đúng filter của `FastRequest`
480
+
481
+ Cả hai filter `html` và `stripTags` đã được kiểm chứng **không còn payload nào lọt
482
+ qua**:
483
+
484
+ | Payload | `html` (escape) | `stripTags` (bỏ thẻ) |
485
+ |---|---|---|
486
+ | `<script>alert(1)</script>` | `&lt;script&gt;...` | `alert(1)` |
487
+ | `<img src=x onerror=alert(1)>` | `&lt;img ...&gt;` | *(rỗng)* |
488
+ | `<svg/onload=alert(1)>` | `&lt;svg/...&gt;` | *(rỗng)* |
489
+ | `<a href="javascript:alert(1)">` | `&lt;a href=&quot;...` | `x` |
490
+ | `<iframe src="//evil.com">` | `&lt;iframe ...&gt;` | *(rỗng)* |
491
+ | `<body onload=alert(1)>` | `&lt;body ...&gt;` | *(rỗng)* |
492
+ | `<math><mtext>...<img onerror=alert(1)>` (mXSS) | toàn bộ bị escape | *(rỗng)* |
493
+
494
+ **Chọn filter nào**
495
+
496
+ | | `html` | `stripTags` |
497
+ |---|---|---|
498
+ | Cơ chế | escape `& < > " ' \`` | xoá mọi thẻ |
499
+ | Bypass | không thể | không thể |
500
+ | Giữ nguyên văn bản thuần | ✅ | ✅ |
501
+ | Giữ thẻ HTML | ❌ (thành text) | ❌ (bị xoá) |
502
+ | Mất dữ liệu ở văn bản thuần | không | **có** — `a < b` → `a b` |
503
+
504
+ - Muốn **giữ nguyên** nội dung để render HTML → dùng `html` (escape an toàn nhất).
505
+ - Muốn lấy **text thuần** (hiển thị, notification, CSV) → dùng `stripTags`, nhưng
506
+ nhớ nó sẽ nuốt mất các ký tự `<` / `>` trong văn bản thường.
507
+ - Cần lưu **rich-text** (nội dung từ trình soạn thảo WYSIWYG) → `html` và
508
+ `stripTags` đều không phù hợp; hãy tự thêm thư viện chuyên dụng
509
+ (`sanitize-html` cho server, `DOMPurify` cho trình duyệt).
510
+
511
+ Hàm `escapeHtml()` được export riêng để dùng độc lập nếu bạn cần escape thủ công.
512
+
513
+ ---
514
+
515
+ ## ⚠️ Thay đổi phá vỡ (breaking changes)
516
+
517
+ ### Lần hardening bảo mật
518
+
519
+ | Thay đổi | Ảnh hưởng | Cách xử lý |
520
+ |---|---|---|
521
+ | `Route.addGS()` sinh `POST /delete/:code` thay vì `GET` | Client gọi `GET` sẽ nhận 404 | Đổi method sang `POST` và gửi CSRF token |
522
+ | `JWTApp.verifyToken()` **từ chối token hết hạn** | Token cũ hết hiệu lực ngay | Xây dựng luồng refresh token |
523
+ | `JWTApp` ném `missing_jwt_key` khi thiếu `JWT_KEY` | App khởi động lỗi nếu chưa cấu hình | Đặt `JWT_KEY` trong `.env` |
524
+ | `FileUpload.upload()/removeFile()/copyFiles()` từ chối đường dẫn chứa `..` | Tham số `folders` cũ chứa `../` sẽ ném `invalid_upload_folder` | Dùng đường dẫn tương đối, ví dụ `images/2026` |
525
+ | `Validation` chỉ chấp nhận rule trong allowlist | Rule tự định nghĩa không còn chạy | Chỉ dùng 19 rule được liệt kê |
526
+ | `Validation.runValidate()` xoá lỗi cũ ở mỗi lần gọi | Không còn cần `clearErrors()` thủ công | Bỏ các lời gọi `clearErrors()` dư thừa |
527
+ | `Validation` rule `phoneVn` chỉ nhận đúng 10 chữ số với tiền tố `03/05/07/08/09` | Chuỗi dài hơn hoặc có ký tự thừa bị từ chối | Bỏ khoảng trắng/ký tự thừa trước khi validate |
528
+ | `Validation` rule `integer` từ chối `'12abc'` và `1.5` | Giá trị trước đây hợp lệ giờ bị từ chối | Ép kiểu số trước khi validate |
529
+ | `Validation` rule `password` chặn chuỗi > 32 ký tự hoặc chứa ký tự lạ | Mật khẩu dài bị từ chối | Ràng buộc độ dài 8–32 như tài liệu |
530
+ | `Validation` rule `inArray` kiểm tra **giá trị** (membership) thay vì index của mảng | `inArray(a)` với `'a'` giờ **pass** (trước luôn fail) | Không cần làm gì — đây là sửa lỗi |
531
+ | `BaseModel.update()` nhận toán tử nâng cao và tôn trọng soft delete | SQL `UPDATE` thay đổi; `update()` bỏ qua bản ghi đã xoá mềm | Dùng `buildWhere` điều kiện mong muốn |
532
+ | `BaseModel.update()` ném `invalid_update_filter` (thay vì thông báo tiếng Việt) | Bắt lỗi theo message cần đổi | Bắt theo `invalid_update_filter` |
533
+ | `BaseModel.updateAdv()` không còn nuốt lỗi thành `invalid_update_is_deleted` | Lỗi database thật được trả về | Bắt lỗi theo message gốc |
534
+ | `BaseModel.isField()` không còn dùng `SHOW COLUMNS ... LIKE ?` | SQL thay đổi; schema được cache lại | Gọi `clearSchemaCache()` sau khi migrate |
535
+ | Rule `number` trả `false` thay vì ném `INVALID_SAFE_NUMBER` | Lỗi vượt safe range nay đi qua con đường validate thông thường; `NaN` cũng bị từ chối | Bỏ `catch` dựa trên `INVALID_SAFE_NUMBER` |
536
+ | Rule `email` chấp nhận TLD ≥ 2 chữ cái và ký tự `+`; chặn dấu chấm liên tiếp | Email trước đây bị từ chối giờ hợp lệ | Không cần làm gì — đây là sửa lỗi |
537
+ | Rule `equals`/`notEqual` so sánh sau khi chuẩn hoá chuỗi | Số `1` giờ khớp `equals(1)` | Không cần làm gì |
538
+ | Rule `inArrayNumber` trả `false` với `null`/`undefined`, duyệt value của object | Trước đây ném `TypeError` | Không cần làm gì |
539
+ | `modifiedSync()` gom truy vấn theo `(model, khoá)` | Số lệnh `SELECT` giảm từ N xuống 1 mỗi cặp | Không cần làm gì |
540
+ | `saveResult()` là API mới, `vdSave()` dùng nó nên không còn đọc `this.errors` | `save()`/`getErrors()` giữ nguyên nhưng vẫn là state dùng chung | Dùng `saveResult()` cho code đồng thời |
541
+ | `Route.addGS()` nhận thêm tham số `options.codeParam` | Không bắt buộc, mặc định giữ nguyên | Truyền `{ codeParam: ':code([0-9a-f-]{36})' }` cho UUID |
542
+ | **Filter `html` giờ ESCAPE thay vì xoá thẻ** — đóng lỗ hổng XSS | `<p>a</p>` → `&lt;p&gt;a&lt;/p&gt;` thay vì giữ nguyên | Nếu cần rich-text, tự thêm `sanitize-html`/`DOMPurify` |
543
+ | `IBaseModel.query()` khai báo `Promise<RawQueryResult>` | Khớp với mysql2; khai báo `modifieds` với model thật giờ typecheck được | Dùng `RawQueryResult` nếu tự định nghĩa interface |
544
+ | Dấu `\|` trong chuỗi rule nay mang nghĩa là ký tự `\|` literal | `inArray(a\|b)` kiểm tra giá trị `a|b` thay vì bị tách thành 2 rule | Bỏ dấu `\` nếu trước đó dùng `\|` theo nghĩa cũ |
545
+
546
+ ### Lần sửa lỗi P0
547
+
548
+ | Thay đổi | Ảnh hưởng | Cách xử lý |
549
+ |---|---|---|
550
+ | `types` trỏ đúng `dist/index.d.mts` | TypeScript dùng được kiểu | Không cần làm gì |
551
+ | `BaseModel.deleteMany({})` ném lỗi | Xóa toàn bộ bảng không còn khả thi qua filter rỗng | Luôn truyền điều kiện lọc |
552
+ | `BaseModel<T>` chấp nhận entity khai báo bằng `interface` | Đúng như README hướng dẫn | Không cần làm gì |
553
+ | `RouteController` không đòi index signature | `addGS(link, MyController, module)` typecheck được | Không cần làm gì |
554
+
555
+ ---
556
+
557
+ ## 📄 Bản quyền
558
+
559
+ Giấy phép mã nguồn mở: [ISC](LICENSE).