create-tigra 2.1.4 → 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/_claude/rules/server/project-conventions.md +64 -0
- package/template/server/.env.example +23 -0
- package/template/server/.env.example.production +21 -0
- package/template/server/package.json +1 -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/__tests__/http.test.ts +414 -0
- package/template/server/src/libs/http.ts +66 -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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import axios, {
|
|
2
|
+
type AxiosInstance,
|
|
3
|
+
type InternalAxiosRequestConfig,
|
|
4
|
+
type AxiosResponse,
|
|
5
|
+
isAxiosError,
|
|
6
|
+
} from 'axios';
|
|
7
|
+
import { logger } from '@libs/logger.js';
|
|
8
|
+
import { InternalError } from '@shared/errors/errors.js';
|
|
9
|
+
|
|
10
|
+
const TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
const httpClient: AxiosInstance = axios.create({
|
|
13
|
+
timeout: TIMEOUT_MS,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
Accept: 'application/json',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Request interceptor — log every outbound call
|
|
21
|
+
httpClient.interceptors.request.use(
|
|
22
|
+
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
|
23
|
+
logger.debug(
|
|
24
|
+
{ method: config.method?.toUpperCase(), url: config.url, baseURL: config.baseURL },
|
|
25
|
+
'[HTTP] Outbound request',
|
|
26
|
+
);
|
|
27
|
+
return config;
|
|
28
|
+
},
|
|
29
|
+
(error: unknown): never => {
|
|
30
|
+
logger.error({ err: error }, '[HTTP] Request setup failed');
|
|
31
|
+
throw new InternalError('Outbound HTTP request could not be constructed');
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Response interceptor — log responses and convert AxiosError → InternalError
|
|
36
|
+
httpClient.interceptors.response.use(
|
|
37
|
+
(response: AxiosResponse): AxiosResponse => {
|
|
38
|
+
logger.debug(
|
|
39
|
+
{
|
|
40
|
+
method: response.config.method?.toUpperCase(),
|
|
41
|
+
url: response.config.url,
|
|
42
|
+
status: response.status,
|
|
43
|
+
},
|
|
44
|
+
'[HTTP] Outbound response',
|
|
45
|
+
);
|
|
46
|
+
return response;
|
|
47
|
+
},
|
|
48
|
+
(error: unknown): never => {
|
|
49
|
+
if (isAxiosError(error)) {
|
|
50
|
+
logger.error(
|
|
51
|
+
{
|
|
52
|
+
method: error.config?.method?.toUpperCase(),
|
|
53
|
+
url: error.config?.url,
|
|
54
|
+
status: error.response?.status,
|
|
55
|
+
message: error.message,
|
|
56
|
+
},
|
|
57
|
+
'[HTTP] Outbound request failed',
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
logger.error({ err: error }, '[HTTP] Unexpected outbound error');
|
|
61
|
+
}
|
|
62
|
+
throw new InternalError('Outbound HTTP request failed');
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
export { httpClient };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP Blocking Service
|
|
3
|
+
*
|
|
4
|
+
* Redis-backed IP blocking with two tiers:
|
|
5
|
+
* - Permanent blocks: Redis SET (admin-managed via API)
|
|
6
|
+
* - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
|
|
7
|
+
*
|
|
8
|
+
* Design decisions:
|
|
9
|
+
* - 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
|
+
* - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getRedis } from '@libs/redis.js';
|
|
15
|
+
import { logger } from '@libs/logger.js';
|
|
16
|
+
|
|
17
|
+
// Redis keys
|
|
18
|
+
const BLOCKED_IPS_KEY = 'blocked_ips';
|
|
19
|
+
const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
|
|
20
|
+
const VIOLATION_PREFIX = 'rl_violations:';
|
|
21
|
+
|
|
22
|
+
// Auto-block thresholds
|
|
23
|
+
const AUTO_BLOCK_THRESHOLD = 10; // violations before auto-block
|
|
24
|
+
const AUTO_BLOCK_WINDOW_SECONDS = 300; // 5-minute sliding window
|
|
25
|
+
const AUTO_BLOCK_DURATION_SECONDS = 3600; // block for 1 hour
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if an IP is blocked (permanent or auto-blocked).
|
|
29
|
+
*
|
|
30
|
+
* @param ip - IP address to check
|
|
31
|
+
* @returns true if blocked, false otherwise (including Redis failures)
|
|
32
|
+
*/
|
|
33
|
+
export async function isIpBlocked(ip: string): Promise<boolean> {
|
|
34
|
+
try {
|
|
35
|
+
const redis = getRedis();
|
|
36
|
+
|
|
37
|
+
// Check permanent block list
|
|
38
|
+
const permanent = await redis.sismember(BLOCKED_IPS_KEY, ip);
|
|
39
|
+
if (permanent === 1) return true;
|
|
40
|
+
|
|
41
|
+
// Check auto-block list (score = expiry Unix timestamp)
|
|
42
|
+
const score = await redis.zscore(AUTO_BLOCKED_KEY, ip);
|
|
43
|
+
if (score) {
|
|
44
|
+
const expiresAt = Number(score);
|
|
45
|
+
if (expiresAt > Date.now() / 1000) return true;
|
|
46
|
+
|
|
47
|
+
// Expired — clean up lazily
|
|
48
|
+
await redis.zrem(AUTO_BLOCKED_KEY, ip);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
} catch {
|
|
53
|
+
// Fail open: if Redis is down, don't block
|
|
54
|
+
logger.warn('[IP-BLOCK] Redis unavailable, skipping IP block check');
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Add an IP to the permanent block list (admin-managed).
|
|
61
|
+
*
|
|
62
|
+
* @param ip - IP address to block
|
|
63
|
+
*/
|
|
64
|
+
export async function blockIp(ip: string): Promise<void> {
|
|
65
|
+
const redis = getRedis();
|
|
66
|
+
await redis.sadd(BLOCKED_IPS_KEY, ip);
|
|
67
|
+
logger.info({ ip }, '[IP-BLOCK] IP permanently blocked');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove an IP from the permanent block list.
|
|
72
|
+
*
|
|
73
|
+
* @param ip - IP address to unblock
|
|
74
|
+
*/
|
|
75
|
+
export async function unblockIp(ip: string): Promise<void> {
|
|
76
|
+
const redis = getRedis();
|
|
77
|
+
await redis.srem(BLOCKED_IPS_KEY, ip);
|
|
78
|
+
// Also remove from auto-block list if present
|
|
79
|
+
await redis.zrem(AUTO_BLOCKED_KEY, ip);
|
|
80
|
+
logger.info({ ip }, '[IP-BLOCK] IP unblocked');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* List all currently blocked IPs (permanent + active auto-blocks).
|
|
85
|
+
*
|
|
86
|
+
* @returns Object with permanent and autoBlocked arrays
|
|
87
|
+
*/
|
|
88
|
+
export async function getBlockedIps(): Promise<{
|
|
89
|
+
permanent: string[];
|
|
90
|
+
autoBlocked: string[];
|
|
91
|
+
}> {
|
|
92
|
+
const redis = getRedis();
|
|
93
|
+
const nowSeconds = Date.now() / 1000;
|
|
94
|
+
|
|
95
|
+
const permanent = await redis.smembers(BLOCKED_IPS_KEY);
|
|
96
|
+
|
|
97
|
+
// Get auto-blocked IPs that haven't expired yet
|
|
98
|
+
const autoBlockedWithScores = await redis.zrangebyscore(
|
|
99
|
+
AUTO_BLOCKED_KEY,
|
|
100
|
+
nowSeconds,
|
|
101
|
+
'+inf',
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return { permanent, autoBlocked: autoBlockedWithScores };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Record a rate-limit violation for an IP.
|
|
109
|
+
*
|
|
110
|
+
* If the IP exceeds AUTO_BLOCK_THRESHOLD violations within
|
|
111
|
+
* AUTO_BLOCK_WINDOW_SECONDS, it gets auto-blocked for
|
|
112
|
+
* AUTO_BLOCK_DURATION_SECONDS.
|
|
113
|
+
*
|
|
114
|
+
* Called from the rate-limit `onExceeded` callback.
|
|
115
|
+
*
|
|
116
|
+
* @param ip - IP address that violated rate limit
|
|
117
|
+
*/
|
|
118
|
+
export async function recordRateLimitViolation(ip: string): Promise<void> {
|
|
119
|
+
try {
|
|
120
|
+
const redis = getRedis();
|
|
121
|
+
const key = `${VIOLATION_PREFIX}${ip}`;
|
|
122
|
+
|
|
123
|
+
const count = await redis.incr(key);
|
|
124
|
+
|
|
125
|
+
// Set TTL on first violation (sliding window)
|
|
126
|
+
if (count === 1) {
|
|
127
|
+
await redis.expire(key, AUTO_BLOCK_WINDOW_SECONDS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (count >= AUTO_BLOCK_THRESHOLD) {
|
|
131
|
+
// Auto-block: add to ZSET with expiry timestamp as score
|
|
132
|
+
const expiresAt = Math.floor(Date.now() / 1000) + AUTO_BLOCK_DURATION_SECONDS;
|
|
133
|
+
await redis.zadd(AUTO_BLOCKED_KEY, expiresAt, ip);
|
|
134
|
+
await redis.del(key); // Reset violation counter
|
|
135
|
+
|
|
136
|
+
logger.warn(
|
|
137
|
+
{ ip, violations: count, blockedForSeconds: AUTO_BLOCK_DURATION_SECONDS },
|
|
138
|
+
'[IP-BLOCK] Auto-blocked IP due to excessive rate-limit violations',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Non-critical: don't break the request if violation tracking fails
|
|
143
|
+
logger.warn('[IP-BLOCK] Failed to record rate-limit violation');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
import type { MultipartFile } from '@fastify/multipart';
|
|
8
8
|
import { ValidationError } from '@shared/errors/errors.js';
|
|
9
|
+
import { env } from '@config/env.js';
|
|
9
10
|
import path from 'path';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* File upload constants and constraints
|
|
13
14
|
*/
|
|
14
15
|
export const FILE_UPLOAD_CONSTANTS = {
|
|
15
|
-
MAX_FILE_SIZE:
|
|
16
|
-
ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
|
17
|
-
ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.webp'] as const,
|
|
16
|
+
MAX_FILE_SIZE: env.MAX_FILE_SIZE_MB * 1024 * 1024, // ENV-configurable (default 10MB)
|
|
17
|
+
ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'] as const,
|
|
18
|
+
ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif'] as const,
|
|
18
19
|
AVATAR_MAX_DIMENSION: 512, // pixels
|
|
19
20
|
} as const;
|
|
20
21
|
|
|
@@ -120,7 +120,7 @@ class ImageOptimizerService {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
// Check if format is supported
|
|
123
|
-
const supportedFormats = ['jpeg', 'png', 'webp', 'gif'];
|
|
123
|
+
const supportedFormats = ['jpeg', 'png', 'webp', 'gif', 'heif'];
|
|
124
124
|
if (!supportedFormats.includes(metadata.format)) {
|
|
125
125
|
throw new ValidationError(
|
|
126
126
|
`Image format '${metadata.format}' is not supported`,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { successResponse } from '@shared/responses/successResponse.js';
|
|
4
|
+
import { ValidationError } from '@shared/errors/errors.js';
|
|
5
|
+
import { blockIp, unblockIp, getBlockedIps } from '@libs/ip-block.js';
|
|
6
|
+
|
|
7
|
+
const blockIpSchema = z.object({
|
|
8
|
+
ip: z.string().ip({ message: 'Invalid IP address' }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
type BlockIpBody = z.infer<typeof blockIpSchema>;
|
|
12
|
+
type UnblockIpParams = { ip: string };
|
|
13
|
+
|
|
14
|
+
export async function listBlockedIps(
|
|
15
|
+
_request: FastifyRequest,
|
|
16
|
+
reply: FastifyReply,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const { permanent, autoBlocked } = await getBlockedIps();
|
|
19
|
+
reply.send(successResponse('Blocked IPs retrieved', { permanent, autoBlocked }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function blockIpHandler(
|
|
23
|
+
request: FastifyRequest<{ Body: BlockIpBody }>,
|
|
24
|
+
reply: FastifyReply,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const parsed = blockIpSchema.safeParse(request.body);
|
|
27
|
+
if (!parsed.success) {
|
|
28
|
+
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid IP address', 'INVALID_IP');
|
|
29
|
+
}
|
|
30
|
+
await blockIp(parsed.data.ip);
|
|
31
|
+
reply.status(201).send(successResponse('IP blocked successfully', { ip: parsed.data.ip }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function unblockIpHandler(
|
|
35
|
+
request: FastifyRequest<{ Params: UnblockIpParams }>,
|
|
36
|
+
reply: FastifyReply,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const { ip } = request.params;
|
|
39
|
+
await unblockIp(ip);
|
|
40
|
+
reply.send(successResponse('IP unblocked successfully', { ip }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { authenticate, authorize } from '@libs/auth.js';
|
|
3
|
+
import { RATE_LIMITS } from '@config/rate-limit.config.js';
|
|
4
|
+
import * as adminController from './admin.controller.js';
|
|
5
|
+
|
|
6
|
+
export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
|
|
7
|
+
// All admin routes require authentication + ADMIN role
|
|
8
|
+
fastify.addHook('preValidation', authenticate);
|
|
9
|
+
fastify.addHook('preValidation', authorize('ADMIN'));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* List blocked IPs
|
|
13
|
+
*
|
|
14
|
+
* GET /api/v1/admin/blocked-ips
|
|
15
|
+
* Auth: Required (ADMIN)
|
|
16
|
+
* Returns: { permanent: string[], autoBlocked: string[] }
|
|
17
|
+
*/
|
|
18
|
+
fastify.get('/admin/blocked-ips', {
|
|
19
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
20
|
+
handler: adminController.listBlockedIps,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Block an IP address (permanent)
|
|
25
|
+
*
|
|
26
|
+
* POST /api/v1/admin/blocked-ips
|
|
27
|
+
* Auth: Required (ADMIN)
|
|
28
|
+
* Body: { ip: string }
|
|
29
|
+
*/
|
|
30
|
+
fastify.post('/admin/blocked-ips', {
|
|
31
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
32
|
+
handler: adminController.blockIpHandler,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unblock an IP address
|
|
37
|
+
*
|
|
38
|
+
* DELETE /api/v1/admin/blocked-ips/:ip
|
|
39
|
+
* Auth: Required (ADMIN)
|
|
40
|
+
*/
|
|
41
|
+
fastify.delete('/admin/blocked-ips/:ip', {
|
|
42
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
43
|
+
handler: adminController.unblockIpHandler,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import { authenticate } from '@libs/auth.js';
|
|
3
|
+
import { RATE_LIMITS } from '@config/rate-limit.config.js';
|
|
3
4
|
import * as authController from './auth.controller.js';
|
|
4
5
|
import {
|
|
5
6
|
registerSchema,
|
|
@@ -7,30 +8,24 @@ import {
|
|
|
7
8
|
} from './auth.schemas.js';
|
|
8
9
|
|
|
9
10
|
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
10
|
-
// Register - Strict rate limiting to prevent abuse
|
|
11
|
+
// Register - Strict rate limiting to prevent abuse
|
|
11
12
|
fastify.post('/auth/register', {
|
|
12
13
|
schema: {
|
|
13
14
|
body: registerSchema,
|
|
14
15
|
},
|
|
15
16
|
config: {
|
|
16
|
-
rateLimit:
|
|
17
|
-
max: 5,
|
|
18
|
-
timeWindow: '1 hour',
|
|
19
|
-
},
|
|
17
|
+
rateLimit: RATE_LIMITS.AUTH_REGISTER,
|
|
20
18
|
},
|
|
21
19
|
handler: authController.register,
|
|
22
20
|
});
|
|
23
21
|
|
|
24
|
-
// Login - Strict rate limiting to prevent brute force
|
|
22
|
+
// Login - Strict rate limiting to prevent brute force
|
|
25
23
|
fastify.post('/auth/login', {
|
|
26
24
|
schema: {
|
|
27
25
|
body: loginSchema,
|
|
28
26
|
},
|
|
29
27
|
config: {
|
|
30
|
-
rateLimit:
|
|
31
|
-
max: 10,
|
|
32
|
-
timeWindow: '15 minutes',
|
|
33
|
-
},
|
|
28
|
+
rateLimit: RATE_LIMITS.AUTH_LOGIN,
|
|
34
29
|
},
|
|
35
30
|
handler: authController.login,
|
|
36
31
|
});
|
|
@@ -38,10 +33,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
38
33
|
// Logout - reads refresh token from cookie, no body schema needed
|
|
39
34
|
fastify.post('/auth/logout', {
|
|
40
35
|
config: {
|
|
41
|
-
rateLimit:
|
|
42
|
-
max: 50,
|
|
43
|
-
timeWindow: '15 minutes',
|
|
44
|
-
},
|
|
36
|
+
rateLimit: RATE_LIMITS.AUTH_LOGOUT,
|
|
45
37
|
},
|
|
46
38
|
handler: authController.logout,
|
|
47
39
|
});
|
|
@@ -49,10 +41,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
49
41
|
// Refresh token - reads refresh token from cookie, no body schema needed
|
|
50
42
|
fastify.post('/auth/refresh', {
|
|
51
43
|
config: {
|
|
52
|
-
rateLimit:
|
|
53
|
-
max: 20,
|
|
54
|
-
timeWindow: '15 minutes',
|
|
55
|
-
},
|
|
44
|
+
rateLimit: RATE_LIMITS.AUTH_REFRESH,
|
|
56
45
|
},
|
|
57
46
|
handler: authController.refresh,
|
|
58
47
|
});
|
|
@@ -61,10 +50,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
61
50
|
fastify.get('/auth/me', {
|
|
62
51
|
preValidation: [authenticate],
|
|
63
52
|
config: {
|
|
64
|
-
rateLimit:
|
|
65
|
-
max: 60,
|
|
66
|
-
timeWindow: '1 minute',
|
|
67
|
-
},
|
|
53
|
+
rateLimit: RATE_LIMITS.AUTH_ME,
|
|
68
54
|
},
|
|
69
55
|
handler: authController.me,
|
|
70
56
|
});
|
|
@@ -73,10 +59,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
73
59
|
fastify.get('/auth/sessions', {
|
|
74
60
|
preValidation: [authenticate],
|
|
75
61
|
config: {
|
|
76
|
-
rateLimit:
|
|
77
|
-
max: 30,
|
|
78
|
-
timeWindow: '1 minute',
|
|
79
|
-
},
|
|
62
|
+
rateLimit: RATE_LIMITS.AUTH_SESSIONS,
|
|
80
63
|
},
|
|
81
64
|
handler: authController.getSessions,
|
|
82
65
|
});
|
|
@@ -85,10 +68,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
85
68
|
fastify.post('/auth/logout-all', {
|
|
86
69
|
preValidation: [authenticate],
|
|
87
70
|
config: {
|
|
88
|
-
rateLimit:
|
|
89
|
-
max: 10,
|
|
90
|
-
timeWindow: '15 minutes',
|
|
91
|
-
},
|
|
71
|
+
rateLimit: RATE_LIMITS.AUTH_LOGOUT_ALL,
|
|
92
72
|
},
|
|
93
73
|
handler: authController.logoutAllSessions,
|
|
94
74
|
});
|
|
@@ -9,7 +9,8 @@ import path from 'path';
|
|
|
9
9
|
import { usersService } from './users.service.js';
|
|
10
10
|
import { validateImageFile, validateFileSize } from '@libs/storage/file-validator.js';
|
|
11
11
|
import { successResponse } from '@shared/responses/successResponse.js';
|
|
12
|
-
import
|
|
12
|
+
import { clearAuthCookies } from '@libs/cookies.js';
|
|
13
|
+
import type { GetUserAvatarParams, UpdateProfileInput, ChangePasswordInput, DeleteAccountInput } from './users.schemas.js';
|
|
13
14
|
import type { AuthenticatedRequest } from '@shared/types/index.js';
|
|
14
15
|
import { logger } from '@libs/logger.js';
|
|
15
16
|
import { BadRequestError } from '@shared/errors/errors.js';
|
|
@@ -114,6 +115,74 @@ class UsersController {
|
|
|
114
115
|
// Send file directly
|
|
115
116
|
return reply.sendFile(path.basename(filePath), path.dirname(filePath));
|
|
116
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Update profile handler
|
|
121
|
+
*
|
|
122
|
+
* PATCH /api/v1/users/me
|
|
123
|
+
* Auth: Required (JWT)
|
|
124
|
+
*
|
|
125
|
+
* @param request - Fastify request (authenticated)
|
|
126
|
+
* @param reply - Fastify reply
|
|
127
|
+
*/
|
|
128
|
+
async updateProfile(
|
|
129
|
+
request: FastifyRequest<{ Body: UpdateProfileInput }>,
|
|
130
|
+
reply: FastifyReply
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const userId = request.user.userId;
|
|
133
|
+
|
|
134
|
+
logger.info({ msg: 'Update profile request', userId });
|
|
135
|
+
|
|
136
|
+
const updatedUser = await usersService.updateProfile(userId, request.body);
|
|
137
|
+
|
|
138
|
+
return reply.send(successResponse('Profile updated successfully', updatedUser));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Change password handler
|
|
143
|
+
*
|
|
144
|
+
* PATCH /api/v1/users/me/password
|
|
145
|
+
* Auth: Required (JWT)
|
|
146
|
+
*
|
|
147
|
+
* @param request - Fastify request (authenticated)
|
|
148
|
+
* @param reply - Fastify reply
|
|
149
|
+
*/
|
|
150
|
+
async changePassword(
|
|
151
|
+
request: FastifyRequest<{ Body: ChangePasswordInput }>,
|
|
152
|
+
reply: FastifyReply
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const userId = request.user.userId;
|
|
155
|
+
|
|
156
|
+
logger.info({ msg: 'Change password request', userId });
|
|
157
|
+
|
|
158
|
+
await usersService.changePassword(userId, request.body);
|
|
159
|
+
|
|
160
|
+
return reply.send(successResponse('Password changed successfully', null));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Delete account handler
|
|
165
|
+
*
|
|
166
|
+
* DELETE /api/v1/users/me
|
|
167
|
+
* Auth: Required (JWT)
|
|
168
|
+
*
|
|
169
|
+
* @param request - Fastify request (authenticated)
|
|
170
|
+
* @param reply - Fastify reply
|
|
171
|
+
*/
|
|
172
|
+
async deleteAccount(
|
|
173
|
+
request: FastifyRequest<{ Body: DeleteAccountInput }>,
|
|
174
|
+
reply: FastifyReply
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const userId = request.user.userId;
|
|
177
|
+
|
|
178
|
+
logger.info({ msg: 'Delete account request', userId });
|
|
179
|
+
|
|
180
|
+
await usersService.deleteAccount(userId, request.body.password);
|
|
181
|
+
|
|
182
|
+
clearAuthCookies(reply);
|
|
183
|
+
|
|
184
|
+
return reply.send(successResponse('Account deleted successfully', null));
|
|
185
|
+
}
|
|
117
186
|
}
|
|
118
187
|
|
|
119
188
|
// Export singleton instance
|
|
@@ -71,6 +71,33 @@ class UsersRepository {
|
|
|
71
71
|
select: userSelect,
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Updates a user's profile fields
|
|
77
|
+
*/
|
|
78
|
+
async updateUserProfile(
|
|
79
|
+
userId: string,
|
|
80
|
+
data: { firstName?: string; lastName?: string }
|
|
81
|
+
): Promise<SafeUser> {
|
|
82
|
+
return prisma.user.update({
|
|
83
|
+
where: { id: userId },
|
|
84
|
+
data,
|
|
85
|
+
select: userSelect,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Soft deletes a user by setting deletedAt and isActive = false
|
|
91
|
+
*/
|
|
92
|
+
async softDeleteUser(userId: string): Promise<void> {
|
|
93
|
+
await prisma.user.update({
|
|
94
|
+
where: { id: userId },
|
|
95
|
+
data: {
|
|
96
|
+
deletedAt: new Date(),
|
|
97
|
+
isActive: false,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
// Export singleton instance
|
|
@@ -6,20 +6,99 @@
|
|
|
6
6
|
|
|
7
7
|
import type { FastifyInstance } from 'fastify';
|
|
8
8
|
import { usersController } from './users.controller.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
GetUserAvatarSchema,
|
|
11
|
+
UpdateProfileSchema,
|
|
12
|
+
ChangePasswordSchema,
|
|
13
|
+
DeleteAccountSchema,
|
|
14
|
+
} from './users.schemas.js';
|
|
10
15
|
import { authenticate } from '@libs/auth.js';
|
|
16
|
+
import { RATE_LIMITS } from '@config/rate-limit.config.js';
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Users routes plugin
|
|
14
20
|
*
|
|
15
21
|
* Endpoints:
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
22
|
+
* - PATCH /users/me - Update profile (authenticated)
|
|
23
|
+
* - PATCH /users/me/password - Change password (authenticated)
|
|
24
|
+
* - DELETE /users/me - Delete account (authenticated)
|
|
25
|
+
* - POST /users/avatar - Upload avatar (authenticated)
|
|
26
|
+
* - DELETE /users/avatar - Delete avatar (authenticated)
|
|
27
|
+
* - GET /users/:userId/avatar - Get avatar (public)
|
|
19
28
|
*
|
|
20
29
|
* @param fastify - Fastify instance
|
|
21
30
|
*/
|
|
22
31
|
export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
|
|
32
|
+
// --- Profile Management ---
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Update profile
|
|
36
|
+
*
|
|
37
|
+
* PATCH /api/v1/users/me
|
|
38
|
+
* Body: { firstName?, lastName? }
|
|
39
|
+
* Auth: Required
|
|
40
|
+
* Rate limit: 10 requests per minute
|
|
41
|
+
*/
|
|
42
|
+
fastify.patch(
|
|
43
|
+
'/users/me',
|
|
44
|
+
{
|
|
45
|
+
preValidation: [authenticate],
|
|
46
|
+
schema: {
|
|
47
|
+
body: UpdateProfileSchema,
|
|
48
|
+
},
|
|
49
|
+
config: {
|
|
50
|
+
rateLimit: RATE_LIMITS.USERS_UPDATE_PROFILE,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
usersController.updateProfile.bind(usersController)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Change password
|
|
58
|
+
*
|
|
59
|
+
* PATCH /api/v1/users/me/password
|
|
60
|
+
* Body: { currentPassword, newPassword }
|
|
61
|
+
* Auth: Required
|
|
62
|
+
* Rate limit: 5 requests per minute
|
|
63
|
+
*/
|
|
64
|
+
fastify.patch(
|
|
65
|
+
'/users/me/password',
|
|
66
|
+
{
|
|
67
|
+
preValidation: [authenticate],
|
|
68
|
+
schema: {
|
|
69
|
+
body: ChangePasswordSchema,
|
|
70
|
+
},
|
|
71
|
+
config: {
|
|
72
|
+
rateLimit: RATE_LIMITS.USERS_CHANGE_PASSWORD,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
usersController.changePassword.bind(usersController)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Delete account
|
|
80
|
+
*
|
|
81
|
+
* DELETE /api/v1/users/me
|
|
82
|
+
* Body: { password }
|
|
83
|
+
* Auth: Required
|
|
84
|
+
* Rate limit: 3 requests per minute
|
|
85
|
+
*/
|
|
86
|
+
fastify.delete(
|
|
87
|
+
'/users/me',
|
|
88
|
+
{
|
|
89
|
+
preValidation: [authenticate],
|
|
90
|
+
schema: {
|
|
91
|
+
body: DeleteAccountSchema,
|
|
92
|
+
},
|
|
93
|
+
config: {
|
|
94
|
+
rateLimit: RATE_LIMITS.USERS_DELETE_ACCOUNT,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
usersController.deleteAccount.bind(usersController)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// --- Avatar Management ---
|
|
101
|
+
|
|
23
102
|
/**
|
|
24
103
|
* Upload avatar
|
|
25
104
|
*
|
|
@@ -34,10 +113,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
34
113
|
{
|
|
35
114
|
preValidation: [authenticate],
|
|
36
115
|
config: {
|
|
37
|
-
rateLimit:
|
|
38
|
-
max: 5,
|
|
39
|
-
timeWindow: '1 minute',
|
|
40
|
-
},
|
|
116
|
+
rateLimit: RATE_LIMITS.USERS_UPLOAD_AVATAR,
|
|
41
117
|
},
|
|
42
118
|
},
|
|
43
119
|
usersController.uploadAvatar.bind(usersController)
|
|
@@ -55,10 +131,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
55
131
|
{
|
|
56
132
|
preValidation: [authenticate],
|
|
57
133
|
config: {
|
|
58
|
-
rateLimit:
|
|
59
|
-
max: 10,
|
|
60
|
-
timeWindow: '1 minute',
|
|
61
|
-
},
|
|
134
|
+
rateLimit: RATE_LIMITS.USERS_DELETE_AVATAR,
|
|
62
135
|
},
|
|
63
136
|
},
|
|
64
137
|
usersController.deleteAvatar.bind(usersController)
|
|
@@ -78,10 +151,7 @@ export async function usersRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
78
151
|
params: GetUserAvatarSchema,
|
|
79
152
|
},
|
|
80
153
|
config: {
|
|
81
|
-
rateLimit:
|
|
82
|
-
max: 100,
|
|
83
|
-
timeWindow: '1 minute',
|
|
84
|
-
},
|
|
154
|
+
rateLimit: RATE_LIMITS.USERS_GET_AVATAR,
|
|
85
155
|
},
|
|
86
156
|
},
|
|
87
157
|
usersController.getAvatar.bind(usersController)
|