create-tigra 2.1.5 → 2.3.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/bin/create-tigra.js +2 -0
- package/package.json +4 -1
- package/template/_claude/commands/create-client.md +1 -4
- package/template/_claude/commands/create-server.md +0 -1
- package/template/_claude/rules/client/01-project-structure.md +0 -3
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/server/project-conventions.md +13 -0
- package/template/client/package.json +2 -1
- package/template/server/.env.example +23 -0
- package/template/server/.env.example.production +21 -0
- package/template/server/package.json +2 -1
- package/template/server/postman/collection.json +524 -0
- package/template/server/postman/environment.json +31 -0
- package/template/server/prisma/schema.prisma +17 -1
- package/template/server/src/app.ts +43 -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 +80 -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/auth.ts +45 -1
- package/template/server/src/libs/ip-block.ts +206 -0
- package/template/server/src/libs/requestLogger.ts +1 -1
- package/template/server/src/libs/storage/file-storage.service.ts +65 -18
- package/template/server/src/libs/storage/file-validator.ts +4 -11
- package/template/server/src/libs/storage/image-optimizer.service.ts +1 -1
- package/template/server/src/modules/admin/admin.controller.ts +42 -0
- package/template/server/src/modules/admin/admin.routes.ts +45 -0
- package/template/server/src/modules/auth/auth.repo.ts +18 -0
- package/template/server/src/modules/auth/auth.routes.ts +10 -30
- package/template/server/src/modules/auth/auth.service.ts +52 -26
- package/template/server/src/modules/users/users.controller.ts +92 -5
- package/template/server/src/modules/users/users.repo.ts +27 -0
- package/template/server/src/modules/users/users.routes.ts +210 -19
- package/template/server/src/modules/users/users.schemas.ts +62 -4
- package/template/server/src/modules/users/users.service.ts +124 -3
- package/template/server/src/shared/types/index.ts +2 -0
- package/template/server/tsconfig.json +2 -1
- package/template/server/uploads/avatars/.gitkeep +0 -1
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
{
|
|
2
|
+
"info": {
|
|
3
|
+
"name": "{{PROJECT_DISPLAY_NAME}} API",
|
|
4
|
+
"_postman_id": "{{PROJECT_NAME}}-server-collection",
|
|
5
|
+
"description": "API collection for {{PROJECT_DISPLAY_NAME}}.\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
|
+
"key": "targetUserId",
|
|
37
|
+
"value": ""
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"item": [
|
|
41
|
+
{
|
|
42
|
+
"name": "Auth",
|
|
43
|
+
"description": "Authentication endpoints. No auth required for these requests.",
|
|
44
|
+
"auth": {
|
|
45
|
+
"type": "noauth"
|
|
46
|
+
},
|
|
47
|
+
"item": [
|
|
48
|
+
{
|
|
49
|
+
"name": "Register",
|
|
50
|
+
"request": {
|
|
51
|
+
"method": "POST",
|
|
52
|
+
"header": [
|
|
53
|
+
{
|
|
54
|
+
"key": "Content-Type",
|
|
55
|
+
"value": "application/json"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"body": {
|
|
59
|
+
"mode": "raw",
|
|
60
|
+
"raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\",\n \"firstName\": \"John\",\n \"lastName\": \"Doe\"\n}"
|
|
61
|
+
},
|
|
62
|
+
"url": {
|
|
63
|
+
"raw": "{{baseUrl}}/auth/register",
|
|
64
|
+
"host": ["{{baseUrl}}"],
|
|
65
|
+
"path": ["auth", "register"]
|
|
66
|
+
},
|
|
67
|
+
"description": "Register a new user account."
|
|
68
|
+
},
|
|
69
|
+
"event": [
|
|
70
|
+
{
|
|
71
|
+
"listen": "test",
|
|
72
|
+
"script": {
|
|
73
|
+
"type": "text/javascript",
|
|
74
|
+
"exec": [
|
|
75
|
+
"if (pm.response.code === 201) {",
|
|
76
|
+
" const res = pm.response.json();",
|
|
77
|
+
" if (res.data && res.data.user) {",
|
|
78
|
+
" pm.collectionVariables.set('userId', res.data.user.id);",
|
|
79
|
+
" }",
|
|
80
|
+
" // Tokens are set via httpOnly cookies, not in response body",
|
|
81
|
+
"}"
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"response": []
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "Login",
|
|
90
|
+
"request": {
|
|
91
|
+
"method": "POST",
|
|
92
|
+
"header": [
|
|
93
|
+
{
|
|
94
|
+
"key": "Content-Type",
|
|
95
|
+
"value": "application/json"
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"body": {
|
|
99
|
+
"mode": "raw",
|
|
100
|
+
"raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"Password123\"\n}"
|
|
101
|
+
},
|
|
102
|
+
"url": {
|
|
103
|
+
"raw": "{{baseUrl}}/auth/login",
|
|
104
|
+
"host": ["{{baseUrl}}"],
|
|
105
|
+
"path": ["auth", "login"]
|
|
106
|
+
},
|
|
107
|
+
"description": "Login with email and password. Tokens are set as httpOnly cookies."
|
|
108
|
+
},
|
|
109
|
+
"event": [
|
|
110
|
+
{
|
|
111
|
+
"listen": "test",
|
|
112
|
+
"script": {
|
|
113
|
+
"type": "text/javascript",
|
|
114
|
+
"exec": [
|
|
115
|
+
"if (pm.response.code === 200) {",
|
|
116
|
+
" const res = pm.response.json();",
|
|
117
|
+
" if (res.data && res.data.user) {",
|
|
118
|
+
" pm.collectionVariables.set('userId', res.data.user.id);",
|
|
119
|
+
" }",
|
|
120
|
+
" // Tokens are set via httpOnly cookies, not in response body",
|
|
121
|
+
"}"
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"response": []
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "Refresh Token",
|
|
130
|
+
"request": {
|
|
131
|
+
"method": "POST",
|
|
132
|
+
"header": [],
|
|
133
|
+
"url": {
|
|
134
|
+
"raw": "{{baseUrl}}/auth/refresh",
|
|
135
|
+
"host": ["{{baseUrl}}"],
|
|
136
|
+
"path": ["auth", "refresh"]
|
|
137
|
+
},
|
|
138
|
+
"description": "Refresh access token using the refresh_token cookie."
|
|
139
|
+
},
|
|
140
|
+
"response": []
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"name": "Logout",
|
|
144
|
+
"request": {
|
|
145
|
+
"method": "POST",
|
|
146
|
+
"header": [],
|
|
147
|
+
"url": {
|
|
148
|
+
"raw": "{{baseUrl}}/auth/logout",
|
|
149
|
+
"host": ["{{baseUrl}}"],
|
|
150
|
+
"path": ["auth", "logout"]
|
|
151
|
+
},
|
|
152
|
+
"description": "Logout and clear auth cookies."
|
|
153
|
+
},
|
|
154
|
+
"response": []
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "Get Me",
|
|
158
|
+
"request": {
|
|
159
|
+
"auth": {
|
|
160
|
+
"type": "bearer",
|
|
161
|
+
"bearer": [
|
|
162
|
+
{
|
|
163
|
+
"key": "token",
|
|
164
|
+
"value": "{{accessToken}}",
|
|
165
|
+
"type": "string"
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
"method": "GET",
|
|
170
|
+
"header": [],
|
|
171
|
+
"url": {
|
|
172
|
+
"raw": "{{baseUrl}}/auth/me",
|
|
173
|
+
"host": ["{{baseUrl}}"],
|
|
174
|
+
"path": ["auth", "me"]
|
|
175
|
+
},
|
|
176
|
+
"description": "Get the currently authenticated user."
|
|
177
|
+
},
|
|
178
|
+
"response": []
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "Get Sessions",
|
|
182
|
+
"request": {
|
|
183
|
+
"auth": {
|
|
184
|
+
"type": "bearer",
|
|
185
|
+
"bearer": [
|
|
186
|
+
{
|
|
187
|
+
"key": "token",
|
|
188
|
+
"value": "{{accessToken}}",
|
|
189
|
+
"type": "string"
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
},
|
|
193
|
+
"method": "GET",
|
|
194
|
+
"header": [],
|
|
195
|
+
"url": {
|
|
196
|
+
"raw": "{{baseUrl}}/auth/sessions",
|
|
197
|
+
"host": ["{{baseUrl}}"],
|
|
198
|
+
"path": ["auth", "sessions"]
|
|
199
|
+
},
|
|
200
|
+
"description": "Get all active sessions for the current user."
|
|
201
|
+
},
|
|
202
|
+
"response": []
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"name": "Logout All Sessions",
|
|
206
|
+
"request": {
|
|
207
|
+
"auth": {
|
|
208
|
+
"type": "bearer",
|
|
209
|
+
"bearer": [
|
|
210
|
+
{
|
|
211
|
+
"key": "token",
|
|
212
|
+
"value": "{{accessToken}}",
|
|
213
|
+
"type": "string"
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
"method": "POST",
|
|
218
|
+
"header": [],
|
|
219
|
+
"url": {
|
|
220
|
+
"raw": "{{baseUrl}}/auth/logout-all",
|
|
221
|
+
"host": ["{{baseUrl}}"],
|
|
222
|
+
"path": ["auth", "logout-all"]
|
|
223
|
+
},
|
|
224
|
+
"description": "Logout from all sessions and invalidate all refresh tokens."
|
|
225
|
+
},
|
|
226
|
+
"response": []
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "Users",
|
|
232
|
+
"description": "User profile and avatar management. All requests require authentication (inherited from collection root).",
|
|
233
|
+
"item": [
|
|
234
|
+
{
|
|
235
|
+
"name": "Update Profile",
|
|
236
|
+
"request": {
|
|
237
|
+
"method": "PATCH",
|
|
238
|
+
"header": [
|
|
239
|
+
{
|
|
240
|
+
"key": "Content-Type",
|
|
241
|
+
"value": "application/json"
|
|
242
|
+
}
|
|
243
|
+
],
|
|
244
|
+
"body": {
|
|
245
|
+
"mode": "raw",
|
|
246
|
+
"raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
|
|
247
|
+
},
|
|
248
|
+
"url": {
|
|
249
|
+
"raw": "{{baseUrl}}/users/me",
|
|
250
|
+
"host": ["{{baseUrl}}"],
|
|
251
|
+
"path": ["users", "me"]
|
|
252
|
+
},
|
|
253
|
+
"description": "Update the current user's profile. At least one field (firstName or lastName) must be provided."
|
|
254
|
+
},
|
|
255
|
+
"response": []
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"name": "Change Password",
|
|
259
|
+
"request": {
|
|
260
|
+
"method": "PATCH",
|
|
261
|
+
"header": [
|
|
262
|
+
{
|
|
263
|
+
"key": "Content-Type",
|
|
264
|
+
"value": "application/json"
|
|
265
|
+
}
|
|
266
|
+
],
|
|
267
|
+
"body": {
|
|
268
|
+
"mode": "raw",
|
|
269
|
+
"raw": "{\n \"currentPassword\": \"Password123\",\n \"newPassword\": \"NewPassword456\"\n}"
|
|
270
|
+
},
|
|
271
|
+
"url": {
|
|
272
|
+
"raw": "{{baseUrl}}/users/me/password",
|
|
273
|
+
"host": ["{{baseUrl}}"],
|
|
274
|
+
"path": ["users", "me", "password"]
|
|
275
|
+
},
|
|
276
|
+
"description": "Change the current user's password. Requires current password verification. Invalidates all sessions after successful change."
|
|
277
|
+
},
|
|
278
|
+
"response": []
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
"name": "Delete Account",
|
|
282
|
+
"request": {
|
|
283
|
+
"method": "DELETE",
|
|
284
|
+
"header": [
|
|
285
|
+
{
|
|
286
|
+
"key": "Content-Type",
|
|
287
|
+
"value": "application/json"
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
"body": {
|
|
291
|
+
"mode": "raw",
|
|
292
|
+
"raw": "{\n \"password\": \"Password123\"\n}"
|
|
293
|
+
},
|
|
294
|
+
"url": {
|
|
295
|
+
"raw": "{{baseUrl}}/users/me",
|
|
296
|
+
"host": ["{{baseUrl}}"],
|
|
297
|
+
"path": ["users", "me"]
|
|
298
|
+
},
|
|
299
|
+
"description": "Soft-delete the current user's account. Requires password confirmation. Account data is permanently purged after 30 days."
|
|
300
|
+
},
|
|
301
|
+
"response": []
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"name": "Upload Avatar",
|
|
305
|
+
"request": {
|
|
306
|
+
"method": "POST",
|
|
307
|
+
"header": [],
|
|
308
|
+
"body": {
|
|
309
|
+
"mode": "formdata",
|
|
310
|
+
"formdata": [
|
|
311
|
+
{
|
|
312
|
+
"key": "file",
|
|
313
|
+
"type": "file",
|
|
314
|
+
"src": "",
|
|
315
|
+
"description": "Image file (JPEG, PNG, or WebP, max 10MB)"
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
"url": {
|
|
320
|
+
"raw": "{{baseUrl}}/users/avatar",
|
|
321
|
+
"host": ["{{baseUrl}}"],
|
|
322
|
+
"path": ["users", "avatar"]
|
|
323
|
+
},
|
|
324
|
+
"description": "Upload or replace the current user's avatar. Accepts JPEG, PNG, or WebP. Max 10MB. Image is optimized and converted to WebP."
|
|
325
|
+
},
|
|
326
|
+
"response": []
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"name": "Delete Avatar",
|
|
330
|
+
"request": {
|
|
331
|
+
"method": "DELETE",
|
|
332
|
+
"header": [],
|
|
333
|
+
"url": {
|
|
334
|
+
"raw": "{{baseUrl}}/users/avatar",
|
|
335
|
+
"host": ["{{baseUrl}}"],
|
|
336
|
+
"path": ["users", "avatar"]
|
|
337
|
+
},
|
|
338
|
+
"description": "Delete the current user's avatar."
|
|
339
|
+
},
|
|
340
|
+
"response": []
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"name": "Get Avatar",
|
|
344
|
+
"request": {
|
|
345
|
+
"auth": {
|
|
346
|
+
"type": "noauth"
|
|
347
|
+
},
|
|
348
|
+
"method": "GET",
|
|
349
|
+
"header": [],
|
|
350
|
+
"url": {
|
|
351
|
+
"raw": "{{baseUrl}}/users/{{userId}}/avatar",
|
|
352
|
+
"host": ["{{baseUrl}}"],
|
|
353
|
+
"path": ["users", "{{userId}}", "avatar"]
|
|
354
|
+
},
|
|
355
|
+
"description": "Get a user's avatar image by user ID. Public endpoint — no authentication required."
|
|
356
|
+
},
|
|
357
|
+
"response": []
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"name": "Admin",
|
|
363
|
+
"description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
|
|
364
|
+
"item": [
|
|
365
|
+
{
|
|
366
|
+
"name": "User Management",
|
|
367
|
+
"description": "Manage users by ID. Requires ADMIN role. Set {{targetUserId}} before calling.",
|
|
368
|
+
"item": [
|
|
369
|
+
{
|
|
370
|
+
"name": "Update Profile (by ID)",
|
|
371
|
+
"request": {
|
|
372
|
+
"method": "PATCH",
|
|
373
|
+
"header": [
|
|
374
|
+
{
|
|
375
|
+
"key": "Content-Type",
|
|
376
|
+
"value": "application/json"
|
|
377
|
+
}
|
|
378
|
+
],
|
|
379
|
+
"body": {
|
|
380
|
+
"mode": "raw",
|
|
381
|
+
"raw": "{\n \"firstName\": \"Jane\",\n \"lastName\": \"Smith\"\n}"
|
|
382
|
+
},
|
|
383
|
+
"url": {
|
|
384
|
+
"raw": "{{baseUrl}}/users/{{targetUserId}}",
|
|
385
|
+
"host": ["{{baseUrl}}"],
|
|
386
|
+
"path": ["users", "{{targetUserId}}"]
|
|
387
|
+
},
|
|
388
|
+
"description": "Update a user's profile by ID. Requires owner or admin access."
|
|
389
|
+
},
|
|
390
|
+
"response": []
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"name": "Change Password (by ID)",
|
|
394
|
+
"request": {
|
|
395
|
+
"method": "PATCH",
|
|
396
|
+
"header": [
|
|
397
|
+
{
|
|
398
|
+
"key": "Content-Type",
|
|
399
|
+
"value": "application/json"
|
|
400
|
+
}
|
|
401
|
+
],
|
|
402
|
+
"body": {
|
|
403
|
+
"mode": "raw",
|
|
404
|
+
"raw": "{\n \"newPassword\": \"NewPassword456\"\n}"
|
|
405
|
+
},
|
|
406
|
+
"url": {
|
|
407
|
+
"raw": "{{baseUrl}}/users/{{targetUserId}}/password",
|
|
408
|
+
"host": ["{{baseUrl}}"],
|
|
409
|
+
"path": ["users", "{{targetUserId}}", "password"]
|
|
410
|
+
},
|
|
411
|
+
"description": "Change a user's password by ID. Admin: only newPassword required. Invalidates all target user sessions."
|
|
412
|
+
},
|
|
413
|
+
"response": []
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
"name": "Delete Account (by ID)",
|
|
417
|
+
"request": {
|
|
418
|
+
"method": "DELETE",
|
|
419
|
+
"header": [],
|
|
420
|
+
"url": {
|
|
421
|
+
"raw": "{{baseUrl}}/users/{{targetUserId}}",
|
|
422
|
+
"host": ["{{baseUrl}}"],
|
|
423
|
+
"path": ["users", "{{targetUserId}}"]
|
|
424
|
+
},
|
|
425
|
+
"description": "Soft-delete a user's account by ID. No password required for admin. Invalidates all target user sessions."
|
|
426
|
+
},
|
|
427
|
+
"response": []
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
"name": "Upload Avatar (by ID)",
|
|
431
|
+
"request": {
|
|
432
|
+
"method": "POST",
|
|
433
|
+
"header": [],
|
|
434
|
+
"body": {
|
|
435
|
+
"mode": "formdata",
|
|
436
|
+
"formdata": [
|
|
437
|
+
{
|
|
438
|
+
"key": "file",
|
|
439
|
+
"type": "file",
|
|
440
|
+
"src": "",
|
|
441
|
+
"description": "Image file (JPEG, PNG, or WebP, max 10MB)"
|
|
442
|
+
}
|
|
443
|
+
]
|
|
444
|
+
},
|
|
445
|
+
"url": {
|
|
446
|
+
"raw": "{{baseUrl}}/users/{{targetUserId}}/avatar",
|
|
447
|
+
"host": ["{{baseUrl}}"],
|
|
448
|
+
"path": ["users", "{{targetUserId}}", "avatar"]
|
|
449
|
+
},
|
|
450
|
+
"description": "Upload or replace a user's avatar by ID. Requires admin access. Accepts JPEG, PNG, or WebP."
|
|
451
|
+
},
|
|
452
|
+
"response": []
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"name": "Delete Avatar (by ID)",
|
|
456
|
+
"request": {
|
|
457
|
+
"method": "DELETE",
|
|
458
|
+
"header": [],
|
|
459
|
+
"url": {
|
|
460
|
+
"raw": "{{baseUrl}}/users/{{targetUserId}}/avatar",
|
|
461
|
+
"host": ["{{baseUrl}}"],
|
|
462
|
+
"path": ["users", "{{targetUserId}}", "avatar"]
|
|
463
|
+
},
|
|
464
|
+
"description": "Delete a user's avatar by ID. Requires admin access."
|
|
465
|
+
},
|
|
466
|
+
"response": []
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"name": "List Blocked IPs",
|
|
472
|
+
"request": {
|
|
473
|
+
"method": "GET",
|
|
474
|
+
"header": [],
|
|
475
|
+
"url": {
|
|
476
|
+
"raw": "{{baseUrl}}/admin/blocked-ips",
|
|
477
|
+
"host": ["{{baseUrl}}"],
|
|
478
|
+
"path": ["admin", "blocked-ips"]
|
|
479
|
+
},
|
|
480
|
+
"description": "List all blocked IP addresses. Returns permanent blocks and active auto-blocks separately."
|
|
481
|
+
},
|
|
482
|
+
"response": []
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
"name": "Block IP",
|
|
486
|
+
"request": {
|
|
487
|
+
"method": "POST",
|
|
488
|
+
"header": [
|
|
489
|
+
{
|
|
490
|
+
"key": "Content-Type",
|
|
491
|
+
"value": "application/json"
|
|
492
|
+
}
|
|
493
|
+
],
|
|
494
|
+
"body": {
|
|
495
|
+
"mode": "raw",
|
|
496
|
+
"raw": "{\n \"ip\": \"1.2.3.4\",\n \"reason\": \"Spam bot\"\n}"
|
|
497
|
+
},
|
|
498
|
+
"url": {
|
|
499
|
+
"raw": "{{baseUrl}}/admin/blocked-ips",
|
|
500
|
+
"host": ["{{baseUrl}}"],
|
|
501
|
+
"path": ["admin", "blocked-ips"]
|
|
502
|
+
},
|
|
503
|
+
"description": "Permanently block an IP address. Persisted to database and cached in Redis. Blocked IPs receive 403 IP_BLOCKED on all requests. Optional reason field for audit trail."
|
|
504
|
+
},
|
|
505
|
+
"response": []
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
"name": "Unblock IP",
|
|
509
|
+
"request": {
|
|
510
|
+
"method": "DELETE",
|
|
511
|
+
"header": [],
|
|
512
|
+
"url": {
|
|
513
|
+
"raw": "{{baseUrl}}/admin/blocked-ips/1.2.3.4",
|
|
514
|
+
"host": ["{{baseUrl}}"],
|
|
515
|
+
"path": ["admin", "blocked-ips", "1.2.3.4"]
|
|
516
|
+
},
|
|
517
|
+
"description": "Remove an IP from the permanent block list. Also removes it from auto-blocks if present."
|
|
518
|
+
},
|
|
519
|
+
"response": []
|
|
520
|
+
}
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
]
|
|
524
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "{{PROJECT_NAME}}-server-env",
|
|
3
|
+
"name": "{{PROJECT_DISPLAY_NAME}} 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
|
+
}
|
|
@@ -18,7 +18,7 @@ model User {
|
|
|
18
18
|
password String
|
|
19
19
|
firstName String
|
|
20
20
|
lastName String
|
|
21
|
-
avatarUrl String? // SEO-friendly path: /uploads/
|
|
21
|
+
avatarUrl String? // SEO-friendly path: /uploads/users/{userId}/avatar/{firstName-lastName}-avatar.webp
|
|
22
22
|
role UserRole @default(USER)
|
|
23
23
|
isActive Boolean @default(true)
|
|
24
24
|
deletedAt DateTime?
|
|
@@ -32,6 +32,7 @@ model User {
|
|
|
32
32
|
|
|
33
33
|
refreshTokens RefreshToken[]
|
|
34
34
|
sessions Session[]
|
|
35
|
+
blockedIps BlockedIp[]
|
|
35
36
|
|
|
36
37
|
// Performance indexes for high-traffic scenarios (10K-100K users/day)
|
|
37
38
|
@@index([role]) // For role-based authorization queries
|
|
@@ -75,3 +76,18 @@ model Session {
|
|
|
75
76
|
@@index([expiresAt]) // For expired session cleanup
|
|
76
77
|
@@map("sessions")
|
|
77
78
|
}
|
|
79
|
+
|
|
80
|
+
model BlockedIp {
|
|
81
|
+
id String @id @default(uuid())
|
|
82
|
+
ip String @unique @db.VarChar(45)
|
|
83
|
+
reason String? @db.VarChar(500)
|
|
84
|
+
blockedBy String
|
|
85
|
+
|
|
86
|
+
createdAt DateTime @default(now())
|
|
87
|
+
updatedAt DateTime @updatedAt
|
|
88
|
+
|
|
89
|
+
user User @relation(fields: [blockedBy], references: [id])
|
|
90
|
+
|
|
91
|
+
@@index([ip])
|
|
92
|
+
@@map("blocked_ips")
|
|
93
|
+
}
|
|
@@ -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, syncBlockedIpsToRedis } 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,16 @@ export async function buildApp() {
|
|
|
113
135
|
// Initialize file storage (create directories)
|
|
114
136
|
await fileStorageService.initialize();
|
|
115
137
|
|
|
138
|
+
// --- Sync permanent IP blocks from DB to Redis ---
|
|
139
|
+
await syncBlockedIpsToRedis();
|
|
140
|
+
|
|
141
|
+
// --- IP Block Check (runs before everything else) ---
|
|
142
|
+
app.addHook('onRequest', async (request: FastifyRequest) => {
|
|
143
|
+
if (await isIpBlocked(request.ip)) {
|
|
144
|
+
throw new ForbiddenError('Access denied', 'IP_BLOCKED');
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
116
148
|
// --- Request/Response Logging ---
|
|
117
149
|
const skipLogPaths = new Set(['/api/v1/health', '/api/v1/ready', '/api/v1/live']);
|
|
118
150
|
|
|
@@ -233,9 +265,10 @@ export async function buildApp() {
|
|
|
233
265
|
// --- Routes ---
|
|
234
266
|
await app.register(authRoutes, { prefix: '/api/v1' });
|
|
235
267
|
await app.register(usersRoutes, { prefix: '/api/v1' });
|
|
268
|
+
await app.register(adminRoutes, { prefix: '/api/v1' });
|
|
236
269
|
|
|
237
|
-
// ---
|
|
238
|
-
|
|
270
|
+
// --- Background Jobs ---
|
|
271
|
+
registerJobs(app);
|
|
239
272
|
|
|
240
273
|
return app;
|
|
241
274
|
}
|