create-tigra 1.1.0 → 2.0.1
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/LICENSE +21 -21
- package/README.md +80 -87
- package/bin/create-tigra.js +259 -308
- package/package.json +49 -41
- package/template/_claude/QUICK_REFERENCE.md +193 -0
- package/template/_claude/README.md +53 -0
- package/template/_claude/commands/create-client.md +881 -0
- package/template/_claude/commands/create-server.md +383 -0
- package/template/_claude/rules/client/01-project-structure.md +133 -0
- package/template/_claude/rules/client/02-components-and-types.md +146 -0
- package/template/_claude/rules/client/03-data-and-state.md +156 -0
- package/template/_claude/rules/client/04-design-system.md +185 -0
- package/template/_claude/rules/client/05-security.md +55 -0
- package/template/_claude/rules/client/06-ux-checklist.md +81 -0
- package/template/_claude/rules/client/core.md +42 -0
- package/template/_claude/rules/global/core.md +77 -0
- package/template/_claude/rules/server/core.md +50 -0
- package/template/_claude/rules/server/database.md +124 -0
- package/template/_claude/rules/server/project-conventions.md +150 -0
- package/template/_claude/rules/server/response-handling.md +144 -0
- package/template/client/.env.example +5 -0
- package/template/client/README.md +36 -0
- package/template/client/components.json +23 -0
- package/template/client/eslint.config.mjs +18 -0
- package/template/client/next.config.ts +34 -0
- package/template/client/package.json +44 -0
- package/template/client/postcss.config.mjs +7 -0
- package/template/client/src/app/(auth)/layout.tsx +18 -0
- package/template/client/src/app/(auth)/login/page.tsx +13 -0
- package/template/client/src/app/(auth)/register/page.tsx +13 -0
- package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/error.tsx +27 -0
- package/template/client/src/app/favicon.ico +0 -0
- package/template/client/src/app/globals.css +145 -0
- package/template/client/src/app/layout.tsx +36 -0
- package/template/client/src/app/loading.tsx +11 -0
- package/template/client/src/app/not-found.tsx +23 -0
- package/template/client/src/app/page.tsx +45 -0
- package/template/client/src/app/providers.tsx +43 -0
- package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
- package/template/client/src/components/common/EmptyState.tsx +31 -0
- package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
- package/template/client/src/components/common/Pagination.tsx +55 -0
- package/template/client/src/components/layout/Footer.tsx +17 -0
- package/template/client/src/components/layout/Header.tsx +173 -0
- package/template/client/src/components/layout/MainLayout.tsx +18 -0
- package/template/client/src/components/ui/alert-dialog.tsx +196 -0
- package/template/client/src/components/ui/badge.tsx +48 -0
- package/template/client/src/components/ui/button.tsx +64 -0
- package/template/client/src/components/ui/card.tsx +92 -0
- package/template/client/src/components/ui/input.tsx +21 -0
- package/template/client/src/components/ui/label.tsx +24 -0
- package/template/client/src/components/ui/select.tsx +190 -0
- package/template/client/src/components/ui/skeleton.tsx +13 -0
- package/template/client/src/components/ui/table.tsx +116 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
- package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
- package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
- package/template/client/src/features/auth/services/auth.service.ts +52 -0
- package/template/client/src/features/auth/store/authSlice.ts +38 -0
- package/template/client/src/features/auth/types/auth.types.ts +32 -0
- package/template/client/src/hooks/useDebounce.ts +14 -0
- package/template/client/src/hooks/useLocalStorage.ts +55 -0
- package/template/client/src/hooks/useMediaQuery.ts +27 -0
- package/template/client/src/lib/api/api.types.ts +34 -0
- package/template/client/src/lib/api/axios.config.ts +98 -0
- package/template/client/src/lib/constants/api-endpoints.ts +18 -0
- package/template/client/src/lib/constants/app.constants.ts +12 -0
- package/template/client/src/lib/constants/routes.ts +9 -0
- package/template/client/src/lib/utils/error.ts +32 -0
- package/template/client/src/lib/utils/format.ts +37 -0
- package/template/client/src/lib/utils/security.ts +34 -0
- package/template/client/src/lib/utils.ts +6 -0
- package/template/client/src/middleware.ts +57 -0
- package/template/client/src/store/hooks.ts +7 -0
- package/template/client/src/store/index.ts +12 -0
- package/template/client/src/types/index.ts +3 -0
- package/template/client/tsconfig.json +34 -0
- package/template/gitignore +34 -0
- package/template/server/.dockerignore +66 -0
- package/template/server/.env.example +96 -69
- package/template/server/.env.production.example +90 -0
- package/template/server/Dockerfile +94 -0
- package/template/server/docker-compose.yml +82 -111
- package/template/server/docs/logging.md +62 -0
- package/template/server/eslint.config.mjs +17 -0
- package/template/server/package.json +68 -81
- package/template/server/phpmyadmin-config.php +26 -0
- package/template/server/postman_collection.json +666 -0
- package/template/server/prisma/schema.prisma +77 -93
- package/template/server/prisma/seed.ts +46 -142
- package/template/server/scripts/flush-redis.ts +41 -0
- package/template/server/src/app.ts +243 -71
- package/template/server/src/config/env.ts +67 -94
- package/template/server/src/libs/auth.ts +88 -0
- package/template/server/src/libs/cleanup.ts +35 -0
- package/template/server/src/libs/cookies.ts +46 -0
- package/template/server/src/libs/logger.ts +33 -60
- package/template/server/src/libs/monitoring.ts +205 -0
- package/template/server/src/libs/password.ts +38 -0
- package/template/server/src/libs/prisma.ts +68 -0
- package/template/server/src/libs/redis.ts +60 -79
- package/template/server/src/libs/requestLogger.ts +66 -0
- package/template/server/src/libs/storage/file-storage.service.ts +211 -0
- package/template/server/src/libs/storage/file-validator.ts +97 -0
- package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
- package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
- package/template/server/src/modules/auth/auth.controller.ts +90 -141
- package/template/server/src/modules/auth/auth.repo.ts +120 -218
- package/template/server/src/modules/auth/auth.routes.ts +96 -83
- package/template/server/src/modules/auth/auth.schemas.ts +35 -137
- package/template/server/src/modules/auth/auth.service.ts +286 -329
- package/template/server/src/modules/auth/session.repo.ts +110 -0
- package/template/server/src/modules/users/users.controller.ts +120 -0
- package/template/server/src/modules/users/users.repo.ts +77 -0
- package/template/server/src/modules/users/users.routes.ts +89 -0
- package/template/server/src/modules/users/users.schemas.ts +21 -0
- package/template/server/src/modules/users/users.service.ts +169 -0
- package/template/server/src/server.ts +58 -139
- package/template/server/src/shared/errors/AppError.ts +21 -0
- package/template/server/src/shared/errors/errors.ts +43 -0
- package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
- package/template/server/src/shared/responses/successResponse.ts +17 -0
- package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
- package/template/server/src/shared/types/index.ts +26 -0
- package/template/server/src/test/setup.ts +74 -38
- package/template/server/tsconfig.json +27 -89
- package/template/server/uploads/avatars/.gitkeep +1 -0
- package/template/server/vitest.config.ts +43 -98
- package/template/.agent/rules/client/01-project-structure.md +0 -326
- package/template/.agent/rules/client/02-component-patterns.md +0 -249
- package/template/.agent/rules/client/03-typescript-rules.md +0 -226
- package/template/.agent/rules/client/04-state-management.md +0 -474
- package/template/.agent/rules/client/05-api-integration.md +0 -129
- package/template/.agent/rules/client/06-forms-validation.md +0 -129
- package/template/.agent/rules/client/07-common-patterns.md +0 -150
- package/template/.agent/rules/client/08-color-system.md +0 -93
- package/template/.agent/rules/client/09-security-rules.md +0 -97
- package/template/.agent/rules/client/10-testing-strategy.md +0 -370
- package/template/.agent/rules/global/ai-edit-safety.md +0 -38
- package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
- package/template/.agent/rules/server/02-general-rules.md +0 -111
- package/template/.agent/rules/server/03-migrations.md +0 -20
- package/template/.agent/rules/server/04-pagination.md +0 -130
- package/template/.agent/rules/server/05-project-conventions.md +0 -71
- package/template/.agent/rules/server/06-response-handling.md +0 -173
- package/template/.agent/rules/server/07-testing-strategy.md +0 -506
- package/template/.agent/rules/server/08-observability.md +0 -180
- package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
- package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
- package/template/.agent/rules/server/12-performance-optimization.md +0 -567
- package/template/.claude/rules/client-01-project-structure.md +0 -327
- package/template/.claude/rules/client-02-component-patterns.md +0 -250
- package/template/.claude/rules/client-03-typescript-rules.md +0 -227
- package/template/.claude/rules/client-04-state-management.md +0 -475
- package/template/.claude/rules/client-05-api-integration.md +0 -130
- package/template/.claude/rules/client-06-forms-validation.md +0 -130
- package/template/.claude/rules/client-07-common-patterns.md +0 -151
- package/template/.claude/rules/client-08-color-system.md +0 -94
- package/template/.claude/rules/client-09-security-rules.md +0 -98
- package/template/.claude/rules/client-10-testing-strategy.md +0 -371
- package/template/.claude/rules/global-ai-edit-safety.md +0 -39
- package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
- package/template/.claude/rules/server-02-general-rules.md +0 -112
- package/template/.claude/rules/server-03-migrations.md +0 -21
- package/template/.claude/rules/server-04-pagination.md +0 -131
- package/template/.claude/rules/server-05-project-conventions.md +0 -72
- package/template/.claude/rules/server-06-response-handling.md +0 -174
- package/template/.claude/rules/server-07-testing-strategy.md +0 -507
- package/template/.claude/rules/server-08-observability.md +0 -181
- package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
- package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
- package/template/.claude/rules/server-12-performance-optimization.md +0 -568
- package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
- package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
- package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
- package/template/.cursor/rules/client-04-state-management.mdc +0 -475
- package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
- package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
- package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
- package/template/.cursor/rules/client-08-color-system.mdc +0 -94
- package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
- package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
- package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
- package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
- package/template/.cursor/rules/server-03-migrations.mdc +0 -21
- package/template/.cursor/rules/server-04-pagination.mdc +0 -131
- package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
- package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
- package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
- package/template/.cursor/rules/server-08-observability.mdc +0 -181
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
- package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
- package/template/CLAUDE.md +0 -207
- package/template/server/.tsc-aliasrc.json +0 -13
- package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
- package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
- package/template/server/README.md +0 -183
- package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
- package/template/server/SECURITY.md +0 -190
- package/template/server/Tigra-API.postman_collection.json +0 -733
- package/template/server/biome.json +0 -42
- package/template/server/scripts/fix-all-imports.ps1 +0 -52
- package/template/server/scripts/fix-imports-reference.ps1 +0 -16
- package/template/server/scripts/fix-imports.mjs +0 -55
- package/template/server/scripts/setup-env.js +0 -50
- package/template/server/scripts/wait-for-db.js +0 -60
- package/template/server/src/hooks/request-timing.hook.ts +0 -26
- package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
- package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
- package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
- package/template/server/src/libs/db.ts +0 -76
- package/template/server/src/libs/error-handler.ts +0 -89
- package/template/server/src/libs/queue.ts +0 -79
- package/template/server/src/modules/admin/admin.controller.ts +0 -122
- package/template/server/src/modules/admin/admin.routes.ts +0 -62
- package/template/server/src/modules/admin/admin.schemas.ts +0 -35
- package/template/server/src/modules/admin/admin.service.ts +0 -167
- package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
- package/template/server/src/modules/auth/auth.service.test.ts +0 -119
- package/template/server/src/modules/auth/auth.types.ts +0 -97
- package/template/server/src/modules/resources/resources.controller.ts +0 -218
- package/template/server/src/modules/resources/resources.repo.ts +0 -253
- package/template/server/src/modules/resources/resources.routes.ts +0 -116
- package/template/server/src/modules/resources/resources.schemas.ts +0 -146
- package/template/server/src/modules/resources/resources.service.ts +0 -218
- package/template/server/src/modules/resources/resources.types.ts +0 -73
- package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
- package/template/server/src/plugins/security.plugin.ts +0 -21
- package/template/server/src/routes/health.routes.ts +0 -31
- package/template/server/src/types/fastify.d.ts +0 -36
- package/template/server/src/utils/errors.ts +0 -108
- package/template/server/src/utils/pagination.ts +0 -120
- package/template/server/src/utils/response.ts +0 -110
- package/template/server/src/workers/file.worker.ts +0 -106
- package/template/server/tsconfig.build.json +0 -30
- package/template/server/tsconfig.test.json +0 -22
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Storage Service
|
|
3
|
+
*
|
|
4
|
+
* Handles local file system operations for user-uploaded files.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { logger } from '@libs/logger.js';
|
|
10
|
+
import { InternalError } from '@shared/errors/errors.js';
|
|
11
|
+
import { generateAvatarFilename } from './filename-sanitizer.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* File Storage Service
|
|
15
|
+
*
|
|
16
|
+
* Manages local file storage with user-specific directories and SEO-friendly naming.
|
|
17
|
+
*/
|
|
18
|
+
class FileStorageService {
|
|
19
|
+
private readonly uploadDir: string;
|
|
20
|
+
private readonly avatarsDir: string;
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
// Base upload directory: server/uploads
|
|
24
|
+
this.uploadDir = path.join(process.cwd(), 'uploads');
|
|
25
|
+
// Avatars subdirectory: server/uploads/avatars
|
|
26
|
+
this.avatarsDir = path.join(this.uploadDir, 'avatars');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Saves an avatar image to user-specific directory
|
|
31
|
+
*
|
|
32
|
+
* Process:
|
|
33
|
+
* 1. Create user directory if it doesn't exist
|
|
34
|
+
* 2. Generate SEO-friendly filename
|
|
35
|
+
* 3. Save buffer to file (overwrites existing avatar)
|
|
36
|
+
* 4. Return filename and public URL
|
|
37
|
+
*
|
|
38
|
+
* @param userId - User's unique ID
|
|
39
|
+
* @param buffer - Optimized image buffer
|
|
40
|
+
* @param firstName - User's first name (for SEO filename)
|
|
41
|
+
* @param lastName - User's last name (for SEO filename)
|
|
42
|
+
* @returns Filename and URL of saved avatar
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const { filename, url } = await fileStorageService.saveAvatar(
|
|
47
|
+
* userId,
|
|
48
|
+
* imageBuffer,
|
|
49
|
+
* 'John',
|
|
50
|
+
* 'Doe'
|
|
51
|
+
* );
|
|
52
|
+
* // filename: "john-doe-avatar.webp"
|
|
53
|
+
* // url: "/uploads/avatars/{userId}/john-doe-avatar.webp"
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
async saveAvatar(
|
|
57
|
+
userId: string,
|
|
58
|
+
buffer: Buffer,
|
|
59
|
+
firstName: string,
|
|
60
|
+
lastName: string
|
|
61
|
+
): Promise<{ filename: string; url: string }> {
|
|
62
|
+
try {
|
|
63
|
+
// Ensure user's avatar directory exists
|
|
64
|
+
const userDir = path.join(this.avatarsDir, userId);
|
|
65
|
+
await this.ensureDirectoryExists(userDir);
|
|
66
|
+
|
|
67
|
+
// Generate SEO-friendly filename
|
|
68
|
+
const filename = generateAvatarFilename(firstName, lastName, 'webp');
|
|
69
|
+
const filePath = path.join(userDir, filename);
|
|
70
|
+
|
|
71
|
+
// Write file to disk (overwrites existing)
|
|
72
|
+
await fs.writeFile(filePath, buffer);
|
|
73
|
+
|
|
74
|
+
// Generate public URL
|
|
75
|
+
const url = `/uploads/avatars/${userId}/${filename}`;
|
|
76
|
+
|
|
77
|
+
logger.info({
|
|
78
|
+
msg: 'Avatar saved successfully',
|
|
79
|
+
userId,
|
|
80
|
+
filename,
|
|
81
|
+
fileSize: buffer.length,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return { filename, url };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
logger.error({ err: error, msg: 'Failed to save avatar', userId });
|
|
87
|
+
throw new InternalError('Failed to save avatar file', 'FILE_SAVE_FAILED');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Deletes a user's avatar directory and all contents
|
|
93
|
+
*
|
|
94
|
+
* @param userId - User's unique ID
|
|
95
|
+
* @throws InternalError if deletion fails
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* await fileStorageService.deleteAvatar(userId);
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
async deleteAvatar(userId: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
const userDir = path.join(this.avatarsDir, userId);
|
|
105
|
+
|
|
106
|
+
// Check if directory exists
|
|
107
|
+
const exists = await this.directoryExists(userDir);
|
|
108
|
+
if (!exists) {
|
|
109
|
+
logger.info({ msg: 'Avatar directory does not exist, nothing to delete', userId });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Delete entire user directory and contents
|
|
114
|
+
await fs.rm(userDir, { recursive: true, force: true });
|
|
115
|
+
|
|
116
|
+
logger.info({ msg: 'Avatar deleted successfully', userId });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.error({ err: error, msg: 'Failed to delete avatar', userId });
|
|
119
|
+
throw new InternalError('Failed to delete avatar file', 'FILE_DELETE_FAILED');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Gets the full file system path for an avatar
|
|
125
|
+
*
|
|
126
|
+
* @param userId - User's unique ID
|
|
127
|
+
* @param filename - Avatar filename
|
|
128
|
+
* @returns Absolute file path
|
|
129
|
+
*/
|
|
130
|
+
getAvatarPath(userId: string, filename: string): string {
|
|
131
|
+
return path.join(this.avatarsDir, userId, filename);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Gets the public URL for an avatar
|
|
136
|
+
*
|
|
137
|
+
* @param userId - User's unique ID
|
|
138
|
+
* @param filename - Avatar filename
|
|
139
|
+
* @returns Public URL path
|
|
140
|
+
*/
|
|
141
|
+
getAvatarUrl(userId: string, filename: string): string {
|
|
142
|
+
return `/uploads/avatars/${userId}/${filename}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Checks if a file exists
|
|
147
|
+
*
|
|
148
|
+
* @param filePath - Full file path
|
|
149
|
+
* @returns True if file exists
|
|
150
|
+
*/
|
|
151
|
+
async fileExists(filePath: string): Promise<boolean> {
|
|
152
|
+
try {
|
|
153
|
+
await fs.access(filePath);
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ensures a directory exists, creates it if not
|
|
162
|
+
*
|
|
163
|
+
* @param dirPath - Directory path
|
|
164
|
+
* @private
|
|
165
|
+
*/
|
|
166
|
+
private async ensureDirectoryExists(dirPath: string): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
await fs.access(dirPath);
|
|
169
|
+
} catch {
|
|
170
|
+
// Directory doesn't exist, create it
|
|
171
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
172
|
+
logger.info({ msg: 'Created directory', dirPath });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Checks if a directory exists
|
|
178
|
+
*
|
|
179
|
+
* @param dirPath - Directory path
|
|
180
|
+
* @returns True if directory exists
|
|
181
|
+
* @private
|
|
182
|
+
*/
|
|
183
|
+
private async directoryExists(dirPath: string): Promise<boolean> {
|
|
184
|
+
try {
|
|
185
|
+
const stats = await fs.stat(dirPath);
|
|
186
|
+
return stats.isDirectory();
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Initializes the upload directory structure
|
|
194
|
+
*
|
|
195
|
+
* Creates base uploads directory and avatars subdirectory if they don't exist.
|
|
196
|
+
* Should be called at application startup.
|
|
197
|
+
*/
|
|
198
|
+
async initialize(): Promise<void> {
|
|
199
|
+
try {
|
|
200
|
+
await this.ensureDirectoryExists(this.uploadDir);
|
|
201
|
+
await this.ensureDirectoryExists(this.avatarsDir);
|
|
202
|
+
logger.info({ msg: 'File storage initialized', uploadDir: this.uploadDir });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.error({ err: error, msg: 'Failed to initialize file storage' });
|
|
205
|
+
throw new InternalError('Failed to initialize file storage', 'STORAGE_INIT_FAILED');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Export singleton instance
|
|
211
|
+
export const fileStorageService = new FileStorageService();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Upload Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates uploaded files for security and compliance with upload constraints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MultipartFile } from '@fastify/multipart';
|
|
8
|
+
import { ValidationError } from '@shared/errors/errors.js';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* File upload constants and constraints
|
|
13
|
+
*/
|
|
14
|
+
export const FILE_UPLOAD_CONSTANTS = {
|
|
15
|
+
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB in bytes
|
|
16
|
+
ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp'] as const,
|
|
17
|
+
ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.webp'] as const,
|
|
18
|
+
AVATAR_MAX_DIMENSION: 512, // pixels
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates an uploaded image file
|
|
23
|
+
*
|
|
24
|
+
* Checks:
|
|
25
|
+
* - File size within limits
|
|
26
|
+
* - MIME type is allowed
|
|
27
|
+
* - File extension is allowed (prevents disguised files)
|
|
28
|
+
* - File is not executable
|
|
29
|
+
*
|
|
30
|
+
* @param file - Fastify multipart file object
|
|
31
|
+
* @throws ValidationError if file is invalid
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const file = await request.file();
|
|
36
|
+
* validateImageFile(file); // Throws if invalid
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function validateImageFile(file: MultipartFile): void {
|
|
40
|
+
// Validate file exists
|
|
41
|
+
if (!file) {
|
|
42
|
+
throw new ValidationError('No file was uploaded', 'FILE_REQUIRED');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate file size
|
|
46
|
+
if (!file.file.bytesRead) {
|
|
47
|
+
throw new ValidationError('Uploaded file is empty', 'FILE_EMPTY');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Note: file.file.bytesRead might not be available until the stream is consumed
|
|
51
|
+
// For proper size validation, we'll need to check this during buffer reading
|
|
52
|
+
|
|
53
|
+
// Validate MIME type
|
|
54
|
+
const mimeType = file.mimetype;
|
|
55
|
+
if (!(FILE_UPLOAD_CONSTANTS.ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType)) {
|
|
56
|
+
throw new ValidationError(
|
|
57
|
+
`Invalid file type. Allowed types: ${FILE_UPLOAD_CONSTANTS.ALLOWED_MIME_TYPES.join(', ')}`,
|
|
58
|
+
'INVALID_FILE_TYPE'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate file extension (prevents MIME type spoofing)
|
|
63
|
+
const extension = path.extname(file.filename).toLowerCase();
|
|
64
|
+
if (!(FILE_UPLOAD_CONSTANTS.ALLOWED_EXTENSIONS as readonly string[]).includes(extension)) {
|
|
65
|
+
throw new ValidationError(
|
|
66
|
+
`Invalid file extension. Allowed extensions: ${FILE_UPLOAD_CONSTANTS.ALLOWED_EXTENSIONS.join(', ')}`,
|
|
67
|
+
'INVALID_FILE_EXTENSION'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Prevent executable files (extra security layer)
|
|
72
|
+
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.com', '.pif', '.scr', '.vbs', '.js'];
|
|
73
|
+
const lowerFilename = file.filename.toLowerCase();
|
|
74
|
+
if (dangerousExtensions.some((ext) => lowerFilename.endsWith(ext))) {
|
|
75
|
+
throw new ValidationError('Executable files are not allowed', 'DANGEROUS_FILE');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validates file buffer size
|
|
81
|
+
*
|
|
82
|
+
* @param buffer - File buffer
|
|
83
|
+
* @throws ValidationError if buffer exceeds max size
|
|
84
|
+
*/
|
|
85
|
+
export function validateFileSize(buffer: Buffer): void {
|
|
86
|
+
if (buffer.length === 0) {
|
|
87
|
+
throw new ValidationError('Uploaded file is empty', 'FILE_EMPTY');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (buffer.length > FILE_UPLOAD_CONSTANTS.MAX_FILE_SIZE) {
|
|
91
|
+
const maxSizeMB = FILE_UPLOAD_CONSTANTS.MAX_FILE_SIZE / (1024 * 1024);
|
|
92
|
+
throw new ValidationError(
|
|
93
|
+
`File size exceeds maximum allowed size of ${maxSizeMB}MB`,
|
|
94
|
+
'FILE_TOO_LARGE'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename Sanitizer Utility
|
|
3
|
+
*
|
|
4
|
+
* Generates SEO-friendly, URL-safe filenames from user names.
|
|
5
|
+
* Supports international characters including Georgian letters.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sanitizes text for use in filenames
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* - Convert to lowercase
|
|
13
|
+
* - Replace spaces with hyphens
|
|
14
|
+
* - Keep only: a-z, 0-9, hyphens, Georgian letters (ა-ჰ), common diacritics (à-ÿ)
|
|
15
|
+
* - Replace multiple consecutive hyphens with single hyphen
|
|
16
|
+
* - Remove leading/trailing hyphens
|
|
17
|
+
*
|
|
18
|
+
* @param text - Text to sanitize
|
|
19
|
+
* @returns Sanitized filename-safe string
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* sanitizeForFilename("John Doe") // "john-doe"
|
|
23
|
+
* sanitizeForFilename("მარიამ გელაშვილი") // "მარიამ-გელაშვილი"
|
|
24
|
+
* sanitizeForFilename("José García-López") // "jose-garcia-lopez"
|
|
25
|
+
* sanitizeForFilename("Anna Marie O'Brien") // "anna-marie-obrien"
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeForFilename(text: string): string {
|
|
28
|
+
return (
|
|
29
|
+
text
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.trim()
|
|
32
|
+
// Replace spaces with hyphens
|
|
33
|
+
.replace(/\s+/g, '-')
|
|
34
|
+
// Keep only: a-z, 0-9, hyphens, Georgian letters (ა-ჰ), common diacritics (à-ÿ)
|
|
35
|
+
.replace(/[^a-z0-9\-ა-ჰà-ÿ]/g, '')
|
|
36
|
+
// Replace multiple consecutive hyphens with single hyphen
|
|
37
|
+
.replace(/-+/g, '-')
|
|
38
|
+
// Remove leading/trailing hyphens
|
|
39
|
+
.replace(/^-|-$/g, '')
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generates an SEO-friendly avatar filename based on user's name
|
|
45
|
+
*
|
|
46
|
+
* Format: {firstName}-{lastName}-avatar.{extension}
|
|
47
|
+
*
|
|
48
|
+
* @param firstName - User's first name
|
|
49
|
+
* @param lastName - User's last name
|
|
50
|
+
* @param extension - File extension (default: 'webp')
|
|
51
|
+
* @returns SEO-friendly filename
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* generateAvatarFilename("John", "Doe") // "john-doe-avatar.webp"
|
|
55
|
+
* generateAvatarFilename("მარიამ", "გელაშვილი") // "მარიამ-გელაშვილი-avatar.webp"
|
|
56
|
+
* generateAvatarFilename("José", "García", "jpg") // "jose-garcia-avatar.jpg"
|
|
57
|
+
*/
|
|
58
|
+
export function generateAvatarFilename(
|
|
59
|
+
firstName: string,
|
|
60
|
+
lastName: string,
|
|
61
|
+
extension: string = 'webp'
|
|
62
|
+
): string {
|
|
63
|
+
const sanitizedFirst = sanitizeForFilename(firstName);
|
|
64
|
+
const sanitizedLast = sanitizeForFilename(lastName);
|
|
65
|
+
|
|
66
|
+
// Handle edge case where sanitization results in empty string
|
|
67
|
+
const first = sanitizedFirst || 'user';
|
|
68
|
+
const last = sanitizedLast || 'avatar';
|
|
69
|
+
|
|
70
|
+
return `${first}-${last}-avatar.${extension}`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Optimization Service
|
|
3
|
+
*
|
|
4
|
+
* Handles image processing, optimization, and validation using Sharp.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { FILE_UPLOAD_CONSTANTS } from './file-validator.js';
|
|
9
|
+
import { ValidationError, InternalError } from '@shared/errors/errors.js';
|
|
10
|
+
import { logger } from '@libs/logger.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Image Optimizer Service
|
|
14
|
+
*
|
|
15
|
+
* Provides methods for optimizing and validating images for avatar uploads.
|
|
16
|
+
*/
|
|
17
|
+
class ImageOptimizerService {
|
|
18
|
+
/**
|
|
19
|
+
* Optimizes an image for avatar use
|
|
20
|
+
*
|
|
21
|
+
* Process:
|
|
22
|
+
* 1. Resize to max 512x512 (preserves aspect ratio)
|
|
23
|
+
* 2. Convert to WebP format for best compression
|
|
24
|
+
* 3. Compress to ~85% quality
|
|
25
|
+
* 4. Strip EXIF metadata for privacy
|
|
26
|
+
*
|
|
27
|
+
* @param buffer - Original image buffer
|
|
28
|
+
* @returns Optimized image buffer in WebP format
|
|
29
|
+
* @throws ValidationError if image is invalid
|
|
30
|
+
* @throws InternalError if optimization fails
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const optimized = await imageOptimizerService.optimizeAvatar(imageBuffer);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
async optimizeAvatar(buffer: Buffer): Promise<Buffer> {
|
|
38
|
+
try {
|
|
39
|
+
// Validate that buffer contains a valid image
|
|
40
|
+
const metadata = await sharp(buffer).metadata();
|
|
41
|
+
|
|
42
|
+
if (!metadata.width || !metadata.height) {
|
|
43
|
+
throw new ValidationError('Unable to read image dimensions', 'INVALID_IMAGE');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.info({
|
|
47
|
+
msg: 'Optimizing avatar image',
|
|
48
|
+
originalFormat: metadata.format,
|
|
49
|
+
originalSize: buffer.length,
|
|
50
|
+
originalDimensions: `${metadata.width}x${metadata.height}`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Optimize image
|
|
54
|
+
const optimized = await sharp(buffer)
|
|
55
|
+
// Resize to max 512x512, preserve aspect ratio
|
|
56
|
+
.resize(FILE_UPLOAD_CONSTANTS.AVATAR_MAX_DIMENSION, FILE_UPLOAD_CONSTANTS.AVATAR_MAX_DIMENSION, {
|
|
57
|
+
fit: 'inside', // Preserve aspect ratio, fit within bounds
|
|
58
|
+
withoutEnlargement: true, // Don't upscale smaller images
|
|
59
|
+
})
|
|
60
|
+
// Convert to WebP with quality optimization
|
|
61
|
+
.webp({
|
|
62
|
+
quality: 85, // Balance between quality and file size
|
|
63
|
+
effort: 4, // Compression effort (0-6, higher = better compression but slower)
|
|
64
|
+
})
|
|
65
|
+
// Strip EXIF metadata for privacy
|
|
66
|
+
.withMetadata({
|
|
67
|
+
exif: {},
|
|
68
|
+
icc: undefined,
|
|
69
|
+
})
|
|
70
|
+
.toBuffer();
|
|
71
|
+
|
|
72
|
+
logger.info({
|
|
73
|
+
msg: 'Avatar optimization complete',
|
|
74
|
+
optimizedSize: optimized.length,
|
|
75
|
+
compressionRatio: `${((1 - optimized.length / buffer.length) * 100).toFixed(1)}%`,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return optimized;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Handle Sharp-specific errors
|
|
81
|
+
if (error instanceof Error) {
|
|
82
|
+
if (error.message.includes('Input buffer contains unsupported image format')) {
|
|
83
|
+
throw new ValidationError('Image format is not supported', 'UNSUPPORTED_IMAGE_FORMAT');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (error.message.includes('Input file is missing')) {
|
|
87
|
+
throw new ValidationError('Invalid image data', 'INVALID_IMAGE');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Re-throw ValidationError as-is
|
|
92
|
+
if (error instanceof ValidationError) {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Wrap unexpected errors
|
|
97
|
+
logger.error({ err: error, msg: 'Image optimization failed' });
|
|
98
|
+
throw new InternalError('Failed to optimize image', 'IMAGE_OPTIMIZATION_FAILED');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validates that a buffer contains a valid image
|
|
104
|
+
*
|
|
105
|
+
* @param buffer - Buffer to validate
|
|
106
|
+
* @returns True if buffer is a valid image
|
|
107
|
+
* @throws ValidationError if buffer is invalid
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```typescript
|
|
111
|
+
* await imageOptimizerService.validateImageBuffer(buffer);
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
async validateImageBuffer(buffer: Buffer): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
const metadata = await sharp(buffer).metadata();
|
|
117
|
+
|
|
118
|
+
if (!metadata.format || !metadata.width || !metadata.height) {
|
|
119
|
+
throw new ValidationError('Buffer does not contain a valid image', 'INVALID_IMAGE');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if format is supported
|
|
123
|
+
const supportedFormats = ['jpeg', 'png', 'webp', 'gif'];
|
|
124
|
+
if (!supportedFormats.includes(metadata.format)) {
|
|
125
|
+
throw new ValidationError(
|
|
126
|
+
`Image format '${metadata.format}' is not supported`,
|
|
127
|
+
'UNSUPPORTED_IMAGE_FORMAT'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof ValidationError) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
logger.error({ err: error, msg: 'Image validation failed' });
|
|
138
|
+
throw new ValidationError('Unable to validate image', 'INVALID_IMAGE');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Export singleton instance
|
|
144
|
+
export const imageOptimizerService = new ImageOptimizerService();
|