create-tigra 2.2.0 → 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/package.json +2 -1
- package/template/server/postman/collection.json +114 -5
- package/template/server/postman/environment.json +2 -2
- package/template/server/prisma/schema.prisma +17 -1
- package/template/server/src/app.ts +4 -1
- package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
- package/template/server/src/libs/auth.ts +45 -1
- package/template/server/src/libs/ip-block.ts +90 -29
- 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 +0 -8
- package/template/server/src/modules/admin/admin.controller.ts +4 -3
- package/template/server/src/modules/auth/auth.repo.ts +18 -0
- package/template/server/src/modules/auth/auth.service.ts +52 -26
- package/template/server/src/modules/users/users.controller.ts +39 -21
- package/template/server/src/modules/users/users.routes.ts +127 -6
- package/template/server/src/modules/users/users.schemas.ts +24 -4
- package/template/server/src/modules/users/users.service.ts +23 -10
- package/template/server/src/shared/types/index.ts +2 -0
package/bin/create-tigra.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-tigra",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"url": "https://github.com/blessandsoul/create-tigra/issues"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/blessandsoul/create-tigra#readme",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"create:test": "node -e \"import('fs').then(f=>f.rmSync('testapp',{recursive:true,force:true}))\" && node bin/create-tigra.js testapp"
|
|
44
|
+
},
|
|
42
45
|
"dependencies": {
|
|
43
46
|
"chalk": "^5.4.1",
|
|
44
47
|
"commander": "^13.1.0",
|
|
@@ -180,8 +180,6 @@ export const API_ENDPOINTS = {
|
|
|
180
180
|
LOGOUT: '/auth/logout',
|
|
181
181
|
REFRESH: '/auth/refresh',
|
|
182
182
|
ME: '/auth/me',
|
|
183
|
-
VERIFY_EMAIL: '/auth/verify-email',
|
|
184
|
-
RESEND_VERIFICATION: '/auth/resend-verification',
|
|
185
183
|
REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
|
|
186
184
|
RESET_PASSWORD: '/auth/reset-password',
|
|
187
185
|
},
|
|
@@ -199,7 +197,6 @@ export const ROUTES = {
|
|
|
199
197
|
HOME: '/',
|
|
200
198
|
LOGIN: '/login',
|
|
201
199
|
REGISTER: '/register',
|
|
202
|
-
VERIFY_EMAIL: '/verify-email',
|
|
203
200
|
RESET_PASSWORD: '/reset-password',
|
|
204
201
|
DASHBOARD: '/dashboard',
|
|
205
202
|
PROFILE: '/profile',
|
|
@@ -369,7 +366,7 @@ Auth types from `02-components-and-types.md`:
|
|
|
369
366
|
|
|
370
367
|
#### `src/features/auth/services/auth.service.ts`
|
|
371
368
|
Auth service class from `03-data-and-state.md`:
|
|
372
|
-
- `register`, `login`, `logout`, `refreshToken`, `getMe`, `
|
|
369
|
+
- `register`, `login`, `logout`, `refreshToken`, `getMe`, `requestPasswordReset`, `resetPassword`
|
|
373
370
|
- Uses `apiClient` and `API_ENDPOINTS`
|
|
374
371
|
- Singleton export: `export const authService = new AuthService();`
|
|
375
372
|
|
|
@@ -78,8 +78,6 @@ export const API_ENDPOINTS = {
|
|
|
78
78
|
LOGOUT: '/auth/logout',
|
|
79
79
|
REFRESH: '/auth/refresh',
|
|
80
80
|
ME: '/auth/me',
|
|
81
|
-
VERIFY_EMAIL: '/auth/verify-email',
|
|
82
|
-
RESEND_VERIFICATION: '/auth/resend-verification',
|
|
83
81
|
REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
|
|
84
82
|
RESET_PASSWORD: '/auth/reset-password',
|
|
85
83
|
},
|
|
@@ -103,7 +101,6 @@ export const ROUTES = {
|
|
|
103
101
|
HOME: '/',
|
|
104
102
|
LOGIN: '/login',
|
|
105
103
|
REGISTER: '/register',
|
|
106
|
-
VERIFY_EMAIL: '/verify-email',
|
|
107
104
|
RESET_PASSWORD: '/reset-password',
|
|
108
105
|
DASHBOARD: '/dashboard',
|
|
109
106
|
PROFILE: '/profile',
|
|
@@ -92,7 +92,7 @@ class ItemService {
|
|
|
92
92
|
export const itemService = new ItemService();
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
Auth service methods: `register`, `login`, `logout`, `refreshToken`, `getMe`, `
|
|
95
|
+
Auth service methods: `register`, `login`, `logout`, `refreshToken`, `getMe`, `requestPasswordReset`, `resetPassword`.
|
|
96
96
|
|
|
97
97
|
---
|
|
98
98
|
|
|
@@ -212,3 +212,16 @@ const apiClient = axios.create({ ...httpClient.defaults, baseURL: 'https://api.e
|
|
|
212
212
|
- Always use `httpClient` — never `fetch`, `node:http`, or inline `axios.create()`.
|
|
213
213
|
- Never add auth headers, cookies, or credentials to the singleton itself.
|
|
214
214
|
- Never log response bodies (may contain PII or secrets).
|
|
215
|
+
|
|
216
|
+
## File Storage
|
|
217
|
+
|
|
218
|
+
Upload directory structure: `uploads/users/{userId}/<media-type>/`
|
|
219
|
+
|
|
220
|
+
| Media type | Path | Example |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| Avatar | `uploads/users/{userId}/avatar/` | `uploads/users/abc123/avatar/john-doe-avatar.webp` |
|
|
223
|
+
|
|
224
|
+
- All user media lives under `uploads/users/{userId}/` for easy per-user cleanup.
|
|
225
|
+
- On account purge, delete the entire `uploads/users/{userId}/` directory via `deleteUserMedia()`.
|
|
226
|
+
- Public URL pattern: `/uploads/users/{userId}/<media-type>/{filename}`
|
|
227
|
+
- New media types follow the same pattern: add a subfolder under the user directory.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
-
"lint": "eslint src/"
|
|
9
|
+
"lint": "eslint src/",
|
|
10
|
+
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"@hookform/resolvers": "^5.2.2",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"redis:flush": "tsx scripts/flush-redis.ts",
|
|
23
23
|
"docker:up": "docker compose up -d",
|
|
24
24
|
"docker:down": "docker compose down",
|
|
25
|
-
"docker:logs": "docker compose logs -f"
|
|
25
|
+
"docker:logs": "docker compose logs -f",
|
|
26
|
+
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
26
27
|
},
|
|
27
28
|
"prisma": {
|
|
28
29
|
"seed": "tsx prisma/seed.ts"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"info": {
|
|
3
|
-
"name": "
|
|
4
|
-
"_postman_id": "
|
|
5
|
-
"description": "API collection for
|
|
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
6
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
7
7
|
},
|
|
8
8
|
"auth": {
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
{
|
|
32
32
|
"key": "userId",
|
|
33
33
|
"value": ""
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"key": "targetUserId",
|
|
37
|
+
"value": ""
|
|
34
38
|
}
|
|
35
39
|
],
|
|
36
40
|
"item": [
|
|
@@ -358,6 +362,111 @@
|
|
|
358
362
|
"name": "Admin",
|
|
359
363
|
"description": "Admin-only endpoints. Requires ADMIN role. Auth inherited from collection root.",
|
|
360
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
|
+
},
|
|
361
470
|
{
|
|
362
471
|
"name": "List Blocked IPs",
|
|
363
472
|
"request": {
|
|
@@ -384,14 +493,14 @@
|
|
|
384
493
|
],
|
|
385
494
|
"body": {
|
|
386
495
|
"mode": "raw",
|
|
387
|
-
"raw": "{\n \"ip\": \"1.2.3.4\"\n}"
|
|
496
|
+
"raw": "{\n \"ip\": \"1.2.3.4\",\n \"reason\": \"Spam bot\"\n}"
|
|
388
497
|
},
|
|
389
498
|
"url": {
|
|
390
499
|
"raw": "{{baseUrl}}/admin/blocked-ips",
|
|
391
500
|
"host": ["{{baseUrl}}"],
|
|
392
501
|
"path": ["admin", "blocked-ips"]
|
|
393
502
|
},
|
|
394
|
-
"description": "Permanently block an IP address. Blocked IPs receive 403 IP_BLOCKED on all requests."
|
|
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."
|
|
395
504
|
},
|
|
396
505
|
"response": []
|
|
397
506
|
},
|
|
@@ -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
|
+
}
|
|
@@ -21,7 +21,7 @@ import { adminRoutes } from '@modules/admin/admin.routes.js';
|
|
|
21
21
|
import { fileStorageService } from '@libs/storage/file-storage.service.js';
|
|
22
22
|
import { registerJobs } from '@jobs/index.js';
|
|
23
23
|
import { RATE_LIMIT_ENABLED, getRateLimitRedisStore } from '@config/rate-limit.config.js';
|
|
24
|
-
import { isIpBlocked, recordRateLimitViolation } from '@libs/ip-block.js';
|
|
24
|
+
import { isIpBlocked, recordRateLimitViolation, syncBlockedIpsToRedis } from '@libs/ip-block.js';
|
|
25
25
|
import { ForbiddenError } from '@shared/errors/errors.js';
|
|
26
26
|
import {
|
|
27
27
|
serializerCompiler,
|
|
@@ -135,6 +135,9 @@ export async function buildApp() {
|
|
|
135
135
|
// Initialize file storage (create directories)
|
|
136
136
|
await fileStorageService.initialize();
|
|
137
137
|
|
|
138
|
+
// --- Sync permanent IP blocks from DB to Redis ---
|
|
139
|
+
await syncBlockedIpsToRedis();
|
|
140
|
+
|
|
138
141
|
// --- IP Block Check (runs before everything else) ---
|
|
139
142
|
app.addHook('onRequest', async (request: FastifyRequest) => {
|
|
140
143
|
if (await isIpBlocked(request.ip)) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Cleanup Deleted Accounts Job
|
|
3
3
|
*
|
|
4
4
|
* Permanently purges soft-deleted user accounts after a 30-day retention period.
|
|
5
|
-
* Deletes
|
|
5
|
+
* Deletes all user media from disk and hard-deletes the user record (cascades to
|
|
6
6
|
* refresh tokens and sessions via onDelete: Cascade).
|
|
7
7
|
*
|
|
8
8
|
* Runs once daily.
|
|
@@ -32,7 +32,6 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
|
|
|
32
32
|
},
|
|
33
33
|
select: {
|
|
34
34
|
id: true,
|
|
35
|
-
avatarUrl: true,
|
|
36
35
|
},
|
|
37
36
|
});
|
|
38
37
|
|
|
@@ -49,10 +48,8 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
|
|
|
49
48
|
|
|
50
49
|
for (const user of usersToDelete) {
|
|
51
50
|
try {
|
|
52
|
-
// Delete
|
|
53
|
-
|
|
54
|
-
await fileStorageService.deleteAvatar(user.id);
|
|
55
|
-
}
|
|
51
|
+
// Delete all user media from disk (no-op if dir doesn't exist)
|
|
52
|
+
await fileStorageService.deleteUserMedia(user.id);
|
|
56
53
|
|
|
57
54
|
// Hard delete user record (cascades to RefreshToken + Session)
|
|
58
55
|
await prisma.user.delete({
|
|
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
|
2
2
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
3
|
import { env } from '@config/env.js';
|
|
4
4
|
import { prisma } from '@libs/prisma.js';
|
|
5
|
-
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
5
|
+
import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
|
|
6
6
|
import type { JwtPayload, UserRole } from '@shared/types/index.js';
|
|
7
7
|
|
|
8
8
|
let app: FastifyInstance | null = null;
|
|
@@ -97,3 +97,47 @@ export function authorize(...roles: UserRole[]) {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Middleware: resolves targetUserId from the authenticated user's JWT.
|
|
102
|
+
* Used for /users/me routes.
|
|
103
|
+
* Must run AFTER authenticate.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveMe(
|
|
106
|
+
request: FastifyRequest,
|
|
107
|
+
_reply: FastifyReply,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
request.targetUserId = request.user.userId;
|
|
110
|
+
request.isAdminAction = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Middleware: resolves targetUserId from :userId param.
|
|
115
|
+
* Allows access if the authenticated user is the owner OR has ADMIN role.
|
|
116
|
+
* Must run AFTER authenticate.
|
|
117
|
+
*
|
|
118
|
+
* Sets:
|
|
119
|
+
* - request.targetUserId: the resolved user ID from params
|
|
120
|
+
* - request.isAdminAction: true if admin is acting on a different user
|
|
121
|
+
*/
|
|
122
|
+
export async function resolveTargetUser(
|
|
123
|
+
request: FastifyRequest,
|
|
124
|
+
_reply: FastifyReply,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const params = request.params as { userId?: string };
|
|
127
|
+
const targetUserId = params.userId;
|
|
128
|
+
|
|
129
|
+
if (!targetUserId) {
|
|
130
|
+
throw new BadRequestError('Missing userId parameter', 'MISSING_USER_ID');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isOwner = request.user.userId === targetUserId;
|
|
134
|
+
const isAdmin = request.user.role === 'ADMIN';
|
|
135
|
+
|
|
136
|
+
if (!isOwner && !isAdmin) {
|
|
137
|
+
throw new ForbiddenError('You do not have permission to perform this action');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
request.targetUserId = targetUserId;
|
|
141
|
+
request.isAdminAction = !isOwner && isAdmin;
|
|
142
|
+
}
|
|
143
|
+
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IP Blocking Service
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - Permanent blocks: Redis SET (
|
|
4
|
+
* Two-tier IP blocking:
|
|
5
|
+
* - Permanent blocks: DB (source of truth) + Redis SET (hot cache). Admin-managed via API.
|
|
6
6
|
* - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
|
|
7
7
|
*
|
|
8
8
|
* Design decisions:
|
|
9
|
+
* - DB is the source of truth for permanent blocks — survives Redis restarts
|
|
10
|
+
* - Redis is the hot cache — all runtime checks hit Redis only (O(1))
|
|
11
|
+
* - On server boot, syncBlockedIpsToRedis() loads all permanent blocks from DB into Redis
|
|
9
12
|
* - Fails open: if Redis is down, requests are NOT blocked (availability > security for rate limiting)
|
|
10
|
-
* - O(1) lookups: both SISMEMBER and ZSCORE are constant-time operations
|
|
11
13
|
* - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
|
|
12
14
|
*/
|
|
13
15
|
|
|
16
|
+
import { prisma } from '@libs/prisma.js';
|
|
14
17
|
import { getRedis } from '@libs/redis.js';
|
|
15
18
|
import { logger } from '@libs/logger.js';
|
|
19
|
+
import { ConflictError } from '@shared/errors/errors.js';
|
|
16
20
|
|
|
17
21
|
// Redis keys
|
|
18
22
|
const BLOCKED_IPS_KEY = 'blocked_ips';
|
|
@@ -24,6 +28,30 @@ const AUTO_BLOCK_THRESHOLD = 10; // violations before auto-block
|
|
|
24
28
|
const AUTO_BLOCK_WINDOW_SECONDS = 300; // 5-minute sliding window
|
|
25
29
|
const AUTO_BLOCK_DURATION_SECONDS = 3600; // block for 1 hour
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Sync all permanent blocked IPs from DB to Redis.
|
|
33
|
+
* Called once during server startup.
|
|
34
|
+
*/
|
|
35
|
+
export async function syncBlockedIpsToRedis(): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
const blockedIps = await prisma.blockedIp.findMany({ select: { ip: true } });
|
|
38
|
+
|
|
39
|
+
const redis = getRedis();
|
|
40
|
+
|
|
41
|
+
// Clear stale Redis state and repopulate from DB
|
|
42
|
+
await redis.del(BLOCKED_IPS_KEY);
|
|
43
|
+
|
|
44
|
+
if (blockedIps.length > 0) {
|
|
45
|
+
await redis.sadd(BLOCKED_IPS_KEY, ...blockedIps.map((b) => b.ip));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logger.info(`[IP-BLOCK] Synced ${blockedIps.length} blocked IPs from DB to Redis`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.warn('[IP-BLOCK] Failed to sync blocked IPs from DB to Redis — permanent blocks may not be enforced until next restart');
|
|
51
|
+
logger.debug(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
/**
|
|
28
56
|
* Check if an IP is blocked (permanent or auto-blocked).
|
|
29
57
|
*
|
|
@@ -57,51 +85,84 @@ export async function isIpBlocked(ip: string): Promise<boolean> {
|
|
|
57
85
|
}
|
|
58
86
|
|
|
59
87
|
/**
|
|
60
|
-
*
|
|
88
|
+
* Block an IP permanently. Writes to DB (source of truth) + Redis (hot cache).
|
|
61
89
|
*
|
|
62
90
|
* @param ip - IP address to block
|
|
91
|
+
* @param blockedBy - Admin user ID who initiated the block
|
|
92
|
+
* @param reason - Optional reason for the block
|
|
63
93
|
*/
|
|
64
|
-
export async function blockIp(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
export async function blockIp(
|
|
95
|
+
ip: string,
|
|
96
|
+
blockedBy: string,
|
|
97
|
+
reason?: string,
|
|
98
|
+
): Promise<{ id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }> {
|
|
99
|
+
// Write to DB (source of truth)
|
|
100
|
+
const existing = await prisma.blockedIp.findUnique({ where: { ip } });
|
|
101
|
+
if (existing) {
|
|
102
|
+
throw new ConflictError('IP is already blocked', 'IP_ALREADY_BLOCKED');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const blocked = await prisma.blockedIp.create({
|
|
106
|
+
data: { ip, blockedBy, reason: reason ?? null },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Sync to Redis cache
|
|
110
|
+
try {
|
|
111
|
+
const redis = getRedis();
|
|
112
|
+
await redis.sadd(BLOCKED_IPS_KEY, ip);
|
|
113
|
+
} catch {
|
|
114
|
+
logger.warn({ ip }, '[IP-BLOCK] Failed to sync block to Redis — will be synced on next restart');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logger.info({ ip, blockedBy, reason }, '[IP-BLOCK] IP permanently blocked');
|
|
118
|
+
return blocked;
|
|
68
119
|
}
|
|
69
120
|
|
|
70
121
|
/**
|
|
71
|
-
*
|
|
122
|
+
* Unblock an IP. Removes from DB + Redis + auto-block list.
|
|
72
123
|
*
|
|
73
124
|
* @param ip - IP address to unblock
|
|
74
125
|
*/
|
|
75
126
|
export async function unblockIp(ip: string): Promise<void> {
|
|
76
|
-
|
|
77
|
-
await
|
|
78
|
-
|
|
79
|
-
|
|
127
|
+
// Remove from DB
|
|
128
|
+
await prisma.blockedIp.deleteMany({ where: { ip } });
|
|
129
|
+
|
|
130
|
+
// Remove from Redis (both permanent and auto-block)
|
|
131
|
+
try {
|
|
132
|
+
const redis = getRedis();
|
|
133
|
+
await redis.srem(BLOCKED_IPS_KEY, ip);
|
|
134
|
+
await redis.zrem(AUTO_BLOCKED_KEY, ip);
|
|
135
|
+
} catch {
|
|
136
|
+
logger.warn({ ip }, '[IP-BLOCK] Failed to sync unblock to Redis — will be synced on next restart');
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
logger.info({ ip }, '[IP-BLOCK] IP unblocked');
|
|
81
140
|
}
|
|
82
141
|
|
|
83
142
|
/**
|
|
84
|
-
* List all currently blocked IPs (permanent + active auto-blocks).
|
|
85
|
-
*
|
|
86
|
-
* @returns Object with permanent and autoBlocked arrays
|
|
143
|
+
* List all currently blocked IPs (permanent from DB + active auto-blocks from Redis).
|
|
87
144
|
*/
|
|
88
145
|
export async function getBlockedIps(): Promise<{
|
|
89
|
-
permanent: string[];
|
|
146
|
+
permanent: { id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }[];
|
|
90
147
|
autoBlocked: string[];
|
|
91
148
|
}> {
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
// Permanent blocks from DB (source of truth)
|
|
150
|
+
const permanent = await prisma.blockedIp.findMany({
|
|
151
|
+
select: { id: true, ip: true, reason: true, blockedBy: true, createdAt: true },
|
|
152
|
+
orderBy: { createdAt: 'desc' },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Auto-blocks from Redis
|
|
156
|
+
let autoBlocked: string[] = [];
|
|
157
|
+
try {
|
|
158
|
+
const redis = getRedis();
|
|
159
|
+
const nowSeconds = Date.now() / 1000;
|
|
160
|
+
autoBlocked = await redis.zrangebyscore(AUTO_BLOCKED_KEY, nowSeconds, '+inf');
|
|
161
|
+
} catch {
|
|
162
|
+
logger.warn('[IP-BLOCK] Redis unavailable, cannot retrieve auto-blocked IPs');
|
|
163
|
+
}
|
|
103
164
|
|
|
104
|
-
return { permanent, autoBlocked
|
|
165
|
+
return { permanent, autoBlocked };
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
/**
|
|
@@ -29,7 +29,7 @@ function getStatusColor(statusCode: number): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function formatDuration(ms: number): string {
|
|
32
|
-
if (ms < 1) return `${(ms * 1000).toFixed(0)}
|
|
32
|
+
if (ms < 1) return `${(ms * 1000).toFixed(0)}us`;
|
|
33
33
|
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
34
34
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
35
35
|
}
|