create-tigra 2.1.5 → 2.2.0
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/package.json +1 -1
- package/template/server/.env.example +23 -0
- package/template/server/.env.example.production +21 -0
- package/template/server/postman/collection.json +415 -0
- package/template/server/postman/environment.json +31 -0
- package/template/server/src/app.ts +40 -10
- package/template/server/src/config/env.ts +9 -0
- package/template/server/src/config/rate-limit.config.ts +114 -0
- package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +83 -0
- package/template/server/src/{libs/cleanup.ts → jobs/cleanup-expired-auth.job.ts} +10 -4
- package/template/server/src/jobs/index.ts +20 -0
- package/template/server/src/libs/ip-block.ts +145 -0
- package/template/server/src/libs/storage/file-validator.ts +4 -3
- package/template/server/src/libs/storage/image-optimizer.service.ts +1 -1
- package/template/server/src/modules/admin/admin.controller.ts +41 -0
- package/template/server/src/modules/admin/admin.routes.ts +45 -0
- package/template/server/src/modules/auth/auth.routes.ts +10 -30
- package/template/server/src/modules/users/users.controller.ts +70 -1
- package/template/server/src/modules/users/users.repo.ts +27 -0
- package/template/server/src/modules/users/users.routes.ts +86 -16
- package/template/server/src/modules/users/users.schemas.ts +38 -0
- package/template/server/src/modules/users/users.service.ts +110 -2
- package/template/server/tsconfig.json +2 -1
- package/template/server/uploads/avatars/.gitkeep +0 -1
package/package.json
CHANGED
|
@@ -39,6 +39,29 @@ REDIS_MAX_RETRIES=3
|
|
|
39
39
|
# Connection timeout in milliseconds
|
|
40
40
|
REDIS_CONNECT_TIMEOUT=10000
|
|
41
41
|
|
|
42
|
+
# ===================================================================
|
|
43
|
+
# RATE LIMITING
|
|
44
|
+
# ===================================================================
|
|
45
|
+
|
|
46
|
+
# Master switch to enable/disable rate limiting (default: true)
|
|
47
|
+
# Set to false in development to disable all rate limits
|
|
48
|
+
RATE_LIMIT_ENABLED=true
|
|
49
|
+
|
|
50
|
+
# Multiply all rate limit max values by this factor (default: 1)
|
|
51
|
+
# Set to 10 in development for 10x headroom, or 0.5 for tighter limits
|
|
52
|
+
RATE_LIMIT_MULTIPLIER=1
|
|
53
|
+
|
|
54
|
+
# Optional: Override specific critical route limits (uses defaults if not set)
|
|
55
|
+
# RATE_LIMIT_AUTH_LOGIN_MAX=10
|
|
56
|
+
# RATE_LIMIT_AUTH_REGISTER_MAX=5
|
|
57
|
+
|
|
58
|
+
# ===================================================================
|
|
59
|
+
# FILE UPLOAD
|
|
60
|
+
# ===================================================================
|
|
61
|
+
|
|
62
|
+
# Maximum file upload size in MB (default: 10)
|
|
63
|
+
MAX_FILE_SIZE_MB=10
|
|
64
|
+
|
|
42
65
|
# ===================================================================
|
|
43
66
|
# DOCKER PORTS (auto-generated, unique per project)
|
|
44
67
|
# ===================================================================
|
|
@@ -39,6 +39,27 @@ REDIS_URL="redis://:REDIS_PASSWORD@redis.internal:6379"
|
|
|
39
39
|
REDIS_MAX_RETRIES=5
|
|
40
40
|
REDIS_CONNECT_TIMEOUT=5000
|
|
41
41
|
|
|
42
|
+
# ===================================================================
|
|
43
|
+
# RATE LIMITING
|
|
44
|
+
# ===================================================================
|
|
45
|
+
|
|
46
|
+
# Always enabled in production
|
|
47
|
+
RATE_LIMIT_ENABLED=true
|
|
48
|
+
|
|
49
|
+
# Production: keep at 1 (default limits are tuned for production)
|
|
50
|
+
RATE_LIMIT_MULTIPLIER=1
|
|
51
|
+
|
|
52
|
+
# Tight limits for sensitive auth routes in production
|
|
53
|
+
RATE_LIMIT_AUTH_LOGIN_MAX=10
|
|
54
|
+
RATE_LIMIT_AUTH_REGISTER_MAX=5
|
|
55
|
+
|
|
56
|
+
# ===================================================================
|
|
57
|
+
# FILE UPLOAD
|
|
58
|
+
# ===================================================================
|
|
59
|
+
|
|
60
|
+
# Maximum file upload size in MB
|
|
61
|
+
MAX_FILE_SIZE_MB=10
|
|
62
|
+
|
|
42
63
|
# ===================================================================
|
|
43
64
|
# JWT AUTHENTICATION
|
|
44
65
|
# ===================================================================
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
{
|
|
2
|
+
"info": {
|
|
3
|
+
"name": "Tigra Server API",
|
|
4
|
+
"_postman_id": "tigra-server-collection",
|
|
5
|
+
"description": "API collection for the Tigra server template.\n\nAll authenticated requests inherit Bearer Token auth from the collection root.\nLogin/Register requests auto-set tokens via test scripts.",
|
|
6
|
+
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
7
|
+
},
|
|
8
|
+
"auth": {
|
|
9
|
+
"type": "bearer",
|
|
10
|
+
"bearer": [
|
|
11
|
+
{
|
|
12
|
+
"key": "token",
|
|
13
|
+
"value": "{{accessToken}}",
|
|
14
|
+
"type": "string"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"variable": [
|
|
19
|
+
{
|
|
20
|
+
"key": "baseUrl",
|
|
21
|
+
"value": "http://localhost:8000/api/v1"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "accessToken",
|
|
25
|
+
"value": ""
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"key": "refreshToken",
|
|
29
|
+
"value": ""
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"key": "userId",
|
|
33
|
+
"value": ""
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"item": [
|
|
37
|
+
{
|
|
38
|
+
"name": "Auth",
|
|
39
|
+
"description": "Authentication endpoints. No auth required for these requests.",
|
|
40
|
+
"auth": {
|
|
41
|
+
"type": "noauth"
|
|
42
|
+
},
|
|
43
|
+
"item": [
|
|
44
|
+
{
|
|
45
|
+
"name": "Register",
|
|
46
|
+
"request": {
|
|
47
|
+
"method": "POST",
|
|
48
|
+
"header": [
|
|
49
|
+
{
|
|
50
|
+
"key": "Content-Type",
|
|
51
|
+
"value": "application/json"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"body": {
|
|
55
|
+
"mode": "raw",
|
|
56
|
+
"raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\",\n \"firstName\": \"John\",\n \"lastName\": \"Doe\"\n}"
|
|
57
|
+
},
|
|
58
|
+
"url": {
|
|
59
|
+
"raw": "{{baseUrl}}/auth/register",
|
|
60
|
+
"host": ["{{baseUrl}}"],
|
|
61
|
+
"path": ["auth", "register"]
|
|
62
|
+
},
|
|
63
|
+
"description": "Register a new user account."
|
|
64
|
+
},
|
|
65
|
+
"event": [
|
|
66
|
+
{
|
|
67
|
+
"listen": "test",
|
|
68
|
+
"script": {
|
|
69
|
+
"type": "text/javascript",
|
|
70
|
+
"exec": [
|
|
71
|
+
"if (pm.response.code === 201) {",
|
|
72
|
+
" const res = pm.response.json();",
|
|
73
|
+
" if (res.data && res.data.user) {",
|
|
74
|
+
" pm.collectionVariables.set('userId', res.data.user.id);",
|
|
75
|
+
" }",
|
|
76
|
+
" // Tokens are set via httpOnly cookies, not in response body",
|
|
77
|
+
"}"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"response": []
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "Login",
|
|
86
|
+
"request": {
|
|
87
|
+
"method": "POST",
|
|
88
|
+
"header": [
|
|
89
|
+
{
|
|
90
|
+
"key": "Content-Type",
|
|
91
|
+
"value": "application/json"
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"body": {
|
|
95
|
+
"mode": "raw",
|
|
96
|
+
"raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\"\n}"
|
|
97
|
+
},
|
|
98
|
+
"url": {
|
|
99
|
+
"raw": "{{baseUrl}}/auth/login",
|
|
100
|
+
"host": ["{{baseUrl}}"],
|
|
101
|
+
"path": ["auth", "login"]
|
|
102
|
+
},
|
|
103
|
+
"description": "Login with email and password. Tokens are set as httpOnly cookies."
|
|
104
|
+
},
|
|
105
|
+
"event": [
|
|
106
|
+
{
|
|
107
|
+
"listen": "test",
|
|
108
|
+
"script": {
|
|
109
|
+
"type": "text/javascript",
|
|
110
|
+
"exec": [
|
|
111
|
+
"if (pm.response.code === 200) {",
|
|
112
|
+
" const res = pm.response.json();",
|
|
113
|
+
" if (res.data && res.data.user) {",
|
|
114
|
+
" pm.collectionVariables.set('userId', res.data.user.id);",
|
|
115
|
+
" }",
|
|
116
|
+
" // Tokens are set via httpOnly cookies, not in response body",
|
|
117
|
+
"}"
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
"response": []
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "Refresh Token",
|
|
126
|
+
"request": {
|
|
127
|
+
"method": "POST",
|
|
128
|
+
"header": [],
|
|
129
|
+
"url": {
|
|
130
|
+
"raw": "{{baseUrl}}/auth/refresh",
|
|
131
|
+
"host": ["{{baseUrl}}"],
|
|
132
|
+
"path": ["auth", "refresh"]
|
|
133
|
+
},
|
|
134
|
+
"description": "Refresh access token using the refresh_token cookie."
|
|
135
|
+
},
|
|
136
|
+
"response": []
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "Logout",
|
|
140
|
+
"request": {
|
|
141
|
+
"method": "POST",
|
|
142
|
+
"header": [],
|
|
143
|
+
"url": {
|
|
144
|
+
"raw": "{{baseUrl}}/auth/logout",
|
|
145
|
+
"host": ["{{baseUrl}}"],
|
|
146
|
+
"path": ["auth", "logout"]
|
|
147
|
+
},
|
|
148
|
+
"description": "Logout and clear auth cookies."
|
|
149
|
+
},
|
|
150
|
+
"response": []
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "Get Me",
|
|
154
|
+
"request": {
|
|
155
|
+
"auth": {
|
|
156
|
+
"type": "bearer",
|
|
157
|
+
"bearer": [
|
|
158
|
+
{
|
|
159
|
+
"key": "token",
|
|
160
|
+
"value": "{{accessToken}}",
|
|
161
|
+
"type": "string"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
165
|
+
"method": "GET",
|
|
166
|
+
"header": [],
|
|
167
|
+
"url": {
|
|
168
|
+
"raw": "{{baseUrl}}/auth/me",
|
|
169
|
+
"host": ["{{baseUrl}}"],
|
|
170
|
+
"path": ["auth", "me"]
|
|
171
|
+
},
|
|
172
|
+
"description": "Get the currently authenticated user."
|
|
173
|
+
},
|
|
174
|
+
"response": []
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"name": "Get Sessions",
|
|
178
|
+
"request": {
|
|
179
|
+
"auth": {
|
|
180
|
+
"type": "bearer",
|
|
181
|
+
"bearer": [
|
|
182
|
+
{
|
|
183
|
+
"key": "token",
|
|
184
|
+
"value": "{{accessToken}}",
|
|
185
|
+
"type": "string"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
"method": "GET",
|
|
190
|
+
"header": [],
|
|
191
|
+
"url": {
|
|
192
|
+
"raw": "{{baseUrl}}/auth/sessions",
|
|
193
|
+
"host": ["{{baseUrl}}"],
|
|
194
|
+
"path": ["auth", "sessions"]
|
|
195
|
+
},
|
|
196
|
+
"description": "Get all active sessions for the current user."
|
|
197
|
+
},
|
|
198
|
+
"response": []
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"name": "Logout All Sessions",
|
|
202
|
+
"request": {
|
|
203
|
+
"auth": {
|
|
204
|
+
"type": "bearer",
|
|
205
|
+
"bearer": [
|
|
206
|
+
{
|
|
207
|
+
"key": "token",
|
|
208
|
+
"value": "{{accessToken}}",
|
|
209
|
+
"type": "string"
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
},
|
|
213
|
+
"method": "POST",
|
|
214
|
+
"header": [],
|
|
215
|
+
"url": {
|
|
216
|
+
"raw": "{{baseUrl}}/auth/logout-all",
|
|
217
|
+
"host": ["{{baseUrl}}"],
|
|
218
|
+
"path": ["auth", "logout-all"]
|
|
219
|
+
},
|
|
220
|
+
"description": "Logout from all sessions and invalidate all refresh tokens."
|
|
221
|
+
},
|
|
222
|
+
"response": []
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "Users",
|
|
228
|
+
"description": "User profile and avatar management. All requests require authentication (inherited from collection root).",
|
|
229
|
+
"item": [
|
|
230
|
+
{
|
|
231
|
+
"name": "Update Profile",
|
|
232
|
+
"request": {
|
|
233
|
+
"method": "PATCH",
|
|
234
|
+
"header": [
|
|
235
|
+
{
|
|
236
|
+
"key": "Content-Type",
|
|
237
|
+
"value": "application/json"
|
|
238
|
+
}
|
|
239
|
+
],
|
|
240
|
+
"body": {
|
|
241
|
+
"mode": "raw",
|
|
242
|
+
"raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
|
|
243
|
+
},
|
|
244
|
+
"url": {
|
|
245
|
+
"raw": "{{baseUrl}}/users/me",
|
|
246
|
+
"host": ["{{baseUrl}}"],
|
|
247
|
+
"path": ["users", "me"]
|
|
248
|
+
},
|
|
249
|
+
"description": "Update the current user's profile. At least one field (firstName or lastName) must be provided."
|
|
250
|
+
},
|
|
251
|
+
"response": []
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"name": "Change Password",
|
|
255
|
+
"request": {
|
|
256
|
+
"method": "PATCH",
|
|
257
|
+
"header": [
|
|
258
|
+
{
|
|
259
|
+
"key": "Content-Type",
|
|
260
|
+
"value": "application/json"
|
|
261
|
+
}
|
|
262
|
+
],
|
|
263
|
+
"body": {
|
|
264
|
+
"mode": "raw",
|
|
265
|
+
"raw": "{\n \"currentPassword\": \"Password123\",\n \"newPassword\": \"NewPassword456\"\n}"
|
|
266
|
+
},
|
|
267
|
+
"url": {
|
|
268
|
+
"raw": "{{baseUrl}}/users/me/password",
|
|
269
|
+
"host": ["{{baseUrl}}"],
|
|
270
|
+
"path": ["users", "me", "password"]
|
|
271
|
+
},
|
|
272
|
+
"description": "Change the current user's password. Requires current password verification. Invalidates all sessions after successful change."
|
|
273
|
+
},
|
|
274
|
+
"response": []
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"name": "Delete Account",
|
|
278
|
+
"request": {
|
|
279
|
+
"method": "DELETE",
|
|
280
|
+
"header": [
|
|
281
|
+
{
|
|
282
|
+
"key": "Content-Type",
|
|
283
|
+
"value": "application/json"
|
|
284
|
+
}
|
|
285
|
+
],
|
|
286
|
+
"body": {
|
|
287
|
+
"mode": "raw",
|
|
288
|
+
"raw": "{\n \"password\": \"Password123\"\n}"
|
|
289
|
+
},
|
|
290
|
+
"url": {
|
|
291
|
+
"raw": "{{baseUrl}}/users/me",
|
|
292
|
+
"host": ["{{baseUrl}}"],
|
|
293
|
+
"path": ["users", "me"]
|
|
294
|
+
},
|
|
295
|
+
"description": "Soft-delete the current user's account. Requires password confirmation. Account data is permanently purged after 30 days."
|
|
296
|
+
},
|
|
297
|
+
"response": []
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"name": "Upload Avatar",
|
|
301
|
+
"request": {
|
|
302
|
+
"method": "POST",
|
|
303
|
+
"header": [],
|
|
304
|
+
"body": {
|
|
305
|
+
"mode": "formdata",
|
|
306
|
+
"formdata": [
|
|
307
|
+
{
|
|
308
|
+
"key": "file",
|
|
309
|
+
"type": "file",
|
|
310
|
+
"src": "",
|
|
311
|
+
"description": "Image file (JPEG, PNG, or WebP, max 10MB)"
|
|
312
|
+
}
|
|
313
|
+
]
|
|
314
|
+
},
|
|
315
|
+
"url": {
|
|
316
|
+
"raw": "{{baseUrl}}/users/avatar",
|
|
317
|
+
"host": ["{{baseUrl}}"],
|
|
318
|
+
"path": ["users", "avatar"]
|
|
319
|
+
},
|
|
320
|
+
"description": "Upload or replace the current user's avatar. Accepts JPEG, PNG, or WebP. Max 10MB. Image is optimized and converted to WebP."
|
|
321
|
+
},
|
|
322
|
+
"response": []
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"name": "Delete Avatar",
|
|
326
|
+
"request": {
|
|
327
|
+
"method": "DELETE",
|
|
328
|
+
"header": [],
|
|
329
|
+
"url": {
|
|
330
|
+
"raw": "{{baseUrl}}/users/avatar",
|
|
331
|
+
"host": ["{{baseUrl}}"],
|
|
332
|
+
"path": ["users", "avatar"]
|
|
333
|
+
},
|
|
334
|
+
"description": "Delete the current user's avatar."
|
|
335
|
+
},
|
|
336
|
+
"response": []
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"name": "Get Avatar",
|
|
340
|
+
"request": {
|
|
341
|
+
"auth": {
|
|
342
|
+
"type": "noauth"
|
|
343
|
+
},
|
|
344
|
+
"method": "GET",
|
|
345
|
+
"header": [],
|
|
346
|
+
"url": {
|
|
347
|
+
"raw": "{{baseUrl}}/users/{{userId}}/avatar",
|
|
348
|
+
"host": ["{{baseUrl}}"],
|
|
349
|
+
"path": ["users", "{{userId}}", "avatar"]
|
|
350
|
+
},
|
|
351
|
+
"description": "Get a user's avatar image by user ID. Public endpoint — no authentication required."
|
|
352
|
+
},
|
|
353
|
+
"response": []
|
|
354
|
+
}
|
|
355
|
+
]
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"name": "Admin",
|
|
359
|
+
"description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
|
|
360
|
+
"item": [
|
|
361
|
+
{
|
|
362
|
+
"name": "List Blocked IPs",
|
|
363
|
+
"request": {
|
|
364
|
+
"method": "GET",
|
|
365
|
+
"header": [],
|
|
366
|
+
"url": {
|
|
367
|
+
"raw": "{{baseUrl}}/admin/blocked-ips",
|
|
368
|
+
"host": ["{{baseUrl}}"],
|
|
369
|
+
"path": ["admin", "blocked-ips"]
|
|
370
|
+
},
|
|
371
|
+
"description": "List all blocked IP addresses. Returns permanent blocks and active auto-blocks separately."
|
|
372
|
+
},
|
|
373
|
+
"response": []
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"name": "Block IP",
|
|
377
|
+
"request": {
|
|
378
|
+
"method": "POST",
|
|
379
|
+
"header": [
|
|
380
|
+
{
|
|
381
|
+
"key": "Content-Type",
|
|
382
|
+
"value": "application/json"
|
|
383
|
+
}
|
|
384
|
+
],
|
|
385
|
+
"body": {
|
|
386
|
+
"mode": "raw",
|
|
387
|
+
"raw": "{\n \"ip\": \"1.2.3.4\"\n}"
|
|
388
|
+
},
|
|
389
|
+
"url": {
|
|
390
|
+
"raw": "{{baseUrl}}/admin/blocked-ips",
|
|
391
|
+
"host": ["{{baseUrl}}"],
|
|
392
|
+
"path": ["admin", "blocked-ips"]
|
|
393
|
+
},
|
|
394
|
+
"description": "Permanently block an IP address. Blocked IPs receive 403 IP_BLOCKED on all requests."
|
|
395
|
+
},
|
|
396
|
+
"response": []
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"name": "Unblock IP",
|
|
400
|
+
"request": {
|
|
401
|
+
"method": "DELETE",
|
|
402
|
+
"header": [],
|
|
403
|
+
"url": {
|
|
404
|
+
"raw": "{{baseUrl}}/admin/blocked-ips/1.2.3.4",
|
|
405
|
+
"host": ["{{baseUrl}}"],
|
|
406
|
+
"path": ["admin", "blocked-ips", "1.2.3.4"]
|
|
407
|
+
},
|
|
408
|
+
"description": "Remove an IP from the permanent block list. Also removes it from auto-blocks if present."
|
|
409
|
+
},
|
|
410
|
+
"response": []
|
|
411
|
+
}
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
]
|
|
415
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tigra-server-env",
|
|
3
|
+
"name": "Tigra Server - Local",
|
|
4
|
+
"values": [
|
|
5
|
+
{
|
|
6
|
+
"key": "baseUrl",
|
|
7
|
+
"value": "http://localhost:8000/api/v1",
|
|
8
|
+
"type": "default",
|
|
9
|
+
"enabled": true
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"key": "accessToken",
|
|
13
|
+
"value": "",
|
|
14
|
+
"type": "default",
|
|
15
|
+
"enabled": true
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"key": "refreshToken",
|
|
19
|
+
"value": "",
|
|
20
|
+
"type": "default",
|
|
21
|
+
"enabled": true
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "userId",
|
|
25
|
+
"value": "",
|
|
26
|
+
"type": "default",
|
|
27
|
+
"enabled": true
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"_postman_variable_scope": "environment"
|
|
31
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import Fastify, { type FastifyError } from 'fastify';
|
|
1
|
+
import Fastify, { type FastifyError, type FastifyRequest } from 'fastify';
|
|
2
2
|
import cors from '@fastify/cors';
|
|
3
3
|
import helmet from '@fastify/helmet';
|
|
4
4
|
import rateLimit from '@fastify/rate-limit';
|
|
@@ -17,8 +17,12 @@ import { isAppError } from '@shared/errors/AppError.js';
|
|
|
17
17
|
import { successResponse, errorResponse } from '@shared/responses/successResponse.js';
|
|
18
18
|
import { authRoutes } from '@modules/auth/auth.routes.js';
|
|
19
19
|
import { usersRoutes } from '@modules/users/users.routes.js';
|
|
20
|
+
import { adminRoutes } from '@modules/admin/admin.routes.js';
|
|
20
21
|
import { fileStorageService } from '@libs/storage/file-storage.service.js';
|
|
21
|
-
import {
|
|
22
|
+
import { registerJobs } from '@jobs/index.js';
|
|
23
|
+
import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
|
|
24
|
+
import { isIpBlocked, recordRateLimitViolation } from '@libs/ip-block.js';
|
|
25
|
+
import { ForbiddenError } from '@shared/errors/errors.js';
|
|
22
26
|
import {
|
|
23
27
|
serializerCompiler,
|
|
24
28
|
validatorCompiler,
|
|
@@ -71,11 +75,29 @@ export async function buildApp() {
|
|
|
71
75
|
encodings: ['gzip', 'deflate'],
|
|
72
76
|
});
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// Rate limiting: Redis-backed when available, in-memory fallback
|
|
79
|
+
if (RATE_LIMIT_ENABLED) {
|
|
80
|
+
const redisStore = getRateLimitRedisStore();
|
|
81
|
+
await app.register(rateLimit, {
|
|
82
|
+
global: false,
|
|
83
|
+
max: 100,
|
|
84
|
+
timeWindow: '1 minute',
|
|
85
|
+
redis: redisStore,
|
|
86
|
+
nameSpace: 'rl:',
|
|
87
|
+
skipOnError: true, // Gracefully degrade if Redis fails mid-request
|
|
88
|
+
onExceeded: (request: { ip: string }) => {
|
|
89
|
+
recordRateLimitViolation(request.ip);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
// Register with effectively no limit so per-route configs don't error
|
|
94
|
+
await app.register(rateLimit, {
|
|
95
|
+
global: false,
|
|
96
|
+
max: 1_000_000,
|
|
97
|
+
timeWindow: '1 minute',
|
|
98
|
+
});
|
|
99
|
+
logger.warn('[RATE-LIMIT] Rate limiting is DISABLED (RATE_LIMIT_ENABLED=false)');
|
|
100
|
+
}
|
|
79
101
|
|
|
80
102
|
await app.register(cookie, {
|
|
81
103
|
secret: env.COOKIE_SECRET || env.JWT_SECRET,
|
|
@@ -95,7 +117,7 @@ export async function buildApp() {
|
|
|
95
117
|
// File upload handling (multipart/form-data)
|
|
96
118
|
await app.register(multipart, {
|
|
97
119
|
limits: {
|
|
98
|
-
fileSize:
|
|
120
|
+
fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
|
|
99
121
|
files: 1, // Only one file per request
|
|
100
122
|
},
|
|
101
123
|
});
|
|
@@ -113,6 +135,13 @@ export async function buildApp() {
|
|
|
113
135
|
// Initialize file storage (create directories)
|
|
114
136
|
await fileStorageService.initialize();
|
|
115
137
|
|
|
138
|
+
// --- IP Block Check (runs before everything else) ---
|
|
139
|
+
app.addHook('onRequest', async (request: FastifyRequest) => {
|
|
140
|
+
if (await isIpBlocked(request.ip)) {
|
|
141
|
+
throw new ForbiddenError('Access denied', 'IP_BLOCKED');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
116
145
|
// --- Request/Response Logging ---
|
|
117
146
|
const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
|
|
118
147
|
|
|
@@ -233,9 +262,10 @@ export async function buildApp() {
|
|
|
233
262
|
// --- Routes ---
|
|
234
263
|
await app.register(authRoutes, { prefix: '/api/v1' });
|
|
235
264
|
await app.register(usersRoutes, { prefix: '/api/v1' });
|
|
265
|
+
await app.register(adminRoutes, { prefix: '/api/v1' });
|
|
236
266
|
|
|
237
|
-
// ---
|
|
238
|
-
|
|
267
|
+
// --- Background Jobs ---
|
|
268
|
+
registerJobs(app);
|
|
239
269
|
|
|
240
270
|
return app;
|
|
241
271
|
}
|
|
@@ -21,6 +21,15 @@ const envSchema = z.object({
|
|
|
21
21
|
REDIS_MAX_RETRIES: z.coerce.number().int().min(0).default(3),
|
|
22
22
|
REDIS_CONNECT_TIMEOUT: z.coerce.number().int().min(1000).default(10000), // ms
|
|
23
23
|
|
|
24
|
+
// --- Rate Limiting ---
|
|
25
|
+
RATE_LIMIT_ENABLED: z.coerce.boolean().default(true),
|
|
26
|
+
RATE_LIMIT_MULTIPLIER: z.coerce.number().min(0.1).max(100).default(1),
|
|
27
|
+
RATE_LIMIT_AUTH_LOGIN_MAX: z.coerce.number().int().min(1).optional(),
|
|
28
|
+
RATE_LIMIT_AUTH_REGISTER_MAX: z.coerce.number().int().min(1).optional(),
|
|
29
|
+
|
|
30
|
+
// --- File Upload ---
|
|
31
|
+
MAX_FILE_SIZE_MB: z.coerce.number().min(1).max(100).default(10),
|
|
32
|
+
|
|
24
33
|
// --- JWT Authentication ---
|
|
25
34
|
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
|
|
26
35
|
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|