@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 +466 -136
- package/dist/index.d.mts +1846 -272
- package/dist/index.mjs +1 -1
- package/package.json +71 -57
package/README.md
CHANGED
|
@@ -1,60 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @fastify-core/base
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
---
|
|
6
6
|
|
|
7
|
-
## Tính năng
|
|
7
|
+
## 🚀 Tính năng nổi bật
|
|
8
8
|
|
|
9
|
-
- BaseModel cho
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
-
|
|
23
|
+
---
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
- Fastify >= 5
|
|
22
|
-
- MySQL / mysql2
|
|
23
|
-
- TypeScript (khuyến nghị)
|
|
25
|
+
## 📋 Yêu cầu hệ thống
|
|
24
26
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 📚 Hướng dẫn sử dụng chi tiết
|
|
49
57
|
|
|
50
|
-
### 1.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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|
|
|
77
|
-
password: 'required|
|
|
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
|
|
82
|
-
console.log('
|
|
83
|
-
} catch (error
|
|
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
|
-
|
|
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 {
|
|
241
|
+
import { Validation, initI18n } from '@fastify-core/base';
|
|
92
242
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
284
|
+
import { Validation } from '@fastify-core/base';
|
|
110
285
|
|
|
111
|
-
const
|
|
112
|
-
const
|
|
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
|
-
|
|
115
|
-
console.log(uploaded);
|
|
292
|
+
await validator.runValidate({ so: '0900000000', maVanBan: 'AB123456' }, rules); // ok
|
|
116
293
|
```
|
|
117
294
|
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
349
|
+
---
|
|
139
350
|
|
|
140
|
-
###
|
|
351
|
+
### 7. Định tuyến tập trung (Route)
|
|
141
352
|
|
|
142
|
-
|
|
353
|
+
```ts
|
|
354
|
+
import { Route } from '@fastify-core/base';
|
|
355
|
+
import { ProductController } from './controllers/ProductController.js';
|
|
143
356
|
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
370
|
+
---
|
|
164
371
|
|
|
165
|
-
|
|
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
|
-
|
|
374
|
+
```ts
|
|
375
|
+
import { t, initI18n } from '@fastify-core/base';
|
|
173
376
|
|
|
174
|
-
|
|
377
|
+
initI18n({
|
|
378
|
+
vi: { welcome: 'Chào mừng bạn!' },
|
|
379
|
+
en: { welcome: 'Welcome!' }
|
|
380
|
+
});
|
|
175
381
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
386
|
+
---
|
|
187
387
|
|
|
188
|
-
|
|
388
|
+
### 9. HMAC Signature
|
|
189
389
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
400
|
+
---
|
|
202
401
|
|
|
203
|
-
|
|
402
|
+
### 10. Tiện ích thường dùng (Common & Utils)
|
|
204
403
|
|
|
205
404
|
```ts
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
+
// => '<img src=x onerror=alert(1)>'
|
|
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
|
-
|
|
449
|
+
---
|
|
216
450
|
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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>` | `<script>...` | `alert(1)` |
|
|
487
|
+
| `<img src=x onerror=alert(1)>` | `<img ...>` | *(rỗng)* |
|
|
488
|
+
| `<svg/onload=alert(1)>` | `<svg/...>` | *(rỗng)* |
|
|
489
|
+
| `<a href="javascript:alert(1)">` | `<a href="...` | `x` |
|
|
490
|
+
| `<iframe src="//evil.com">` | `<iframe ...>` | *(rỗng)* |
|
|
491
|
+
| `<body onload=alert(1)>` | `<body ...>` | *(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>` → `<p>a</p>` 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).
|