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.
Files changed (243) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -87
  3. package/bin/create-tigra.js +259 -308
  4. package/package.json +49 -41
  5. package/template/_claude/QUICK_REFERENCE.md +193 -0
  6. package/template/_claude/README.md +53 -0
  7. package/template/_claude/commands/create-client.md +881 -0
  8. package/template/_claude/commands/create-server.md +383 -0
  9. package/template/_claude/rules/client/01-project-structure.md +133 -0
  10. package/template/_claude/rules/client/02-components-and-types.md +146 -0
  11. package/template/_claude/rules/client/03-data-and-state.md +156 -0
  12. package/template/_claude/rules/client/04-design-system.md +185 -0
  13. package/template/_claude/rules/client/05-security.md +55 -0
  14. package/template/_claude/rules/client/06-ux-checklist.md +81 -0
  15. package/template/_claude/rules/client/core.md +42 -0
  16. package/template/_claude/rules/global/core.md +77 -0
  17. package/template/_claude/rules/server/core.md +50 -0
  18. package/template/_claude/rules/server/database.md +124 -0
  19. package/template/_claude/rules/server/project-conventions.md +150 -0
  20. package/template/_claude/rules/server/response-handling.md +144 -0
  21. package/template/client/.env.example +5 -0
  22. package/template/client/README.md +36 -0
  23. package/template/client/components.json +23 -0
  24. package/template/client/eslint.config.mjs +18 -0
  25. package/template/client/next.config.ts +34 -0
  26. package/template/client/package.json +44 -0
  27. package/template/client/postcss.config.mjs +7 -0
  28. package/template/client/src/app/(auth)/layout.tsx +18 -0
  29. package/template/client/src/app/(auth)/login/page.tsx +13 -0
  30. package/template/client/src/app/(auth)/register/page.tsx +13 -0
  31. package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
  32. package/template/client/src/app/(main)/layout.tsx +11 -0
  33. package/template/client/src/app/error.tsx +27 -0
  34. package/template/client/src/app/favicon.ico +0 -0
  35. package/template/client/src/app/globals.css +145 -0
  36. package/template/client/src/app/layout.tsx +36 -0
  37. package/template/client/src/app/loading.tsx +11 -0
  38. package/template/client/src/app/not-found.tsx +23 -0
  39. package/template/client/src/app/page.tsx +45 -0
  40. package/template/client/src/app/providers.tsx +43 -0
  41. package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
  42. package/template/client/src/components/common/EmptyState.tsx +31 -0
  43. package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
  44. package/template/client/src/components/common/Pagination.tsx +55 -0
  45. package/template/client/src/components/layout/Footer.tsx +17 -0
  46. package/template/client/src/components/layout/Header.tsx +173 -0
  47. package/template/client/src/components/layout/MainLayout.tsx +18 -0
  48. package/template/client/src/components/ui/alert-dialog.tsx +196 -0
  49. package/template/client/src/components/ui/badge.tsx +48 -0
  50. package/template/client/src/components/ui/button.tsx +64 -0
  51. package/template/client/src/components/ui/card.tsx +92 -0
  52. package/template/client/src/components/ui/input.tsx +21 -0
  53. package/template/client/src/components/ui/label.tsx +24 -0
  54. package/template/client/src/components/ui/select.tsx +190 -0
  55. package/template/client/src/components/ui/skeleton.tsx +13 -0
  56. package/template/client/src/components/ui/table.tsx +116 -0
  57. package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
  58. package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
  59. package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
  60. package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
  61. package/template/client/src/features/auth/services/auth.service.ts +52 -0
  62. package/template/client/src/features/auth/store/authSlice.ts +38 -0
  63. package/template/client/src/features/auth/types/auth.types.ts +32 -0
  64. package/template/client/src/hooks/useDebounce.ts +14 -0
  65. package/template/client/src/hooks/useLocalStorage.ts +55 -0
  66. package/template/client/src/hooks/useMediaQuery.ts +27 -0
  67. package/template/client/src/lib/api/api.types.ts +34 -0
  68. package/template/client/src/lib/api/axios.config.ts +98 -0
  69. package/template/client/src/lib/constants/api-endpoints.ts +18 -0
  70. package/template/client/src/lib/constants/app.constants.ts +12 -0
  71. package/template/client/src/lib/constants/routes.ts +9 -0
  72. package/template/client/src/lib/utils/error.ts +32 -0
  73. package/template/client/src/lib/utils/format.ts +37 -0
  74. package/template/client/src/lib/utils/security.ts +34 -0
  75. package/template/client/src/lib/utils.ts +6 -0
  76. package/template/client/src/middleware.ts +57 -0
  77. package/template/client/src/store/hooks.ts +7 -0
  78. package/template/client/src/store/index.ts +12 -0
  79. package/template/client/src/types/index.ts +3 -0
  80. package/template/client/tsconfig.json +34 -0
  81. package/template/gitignore +34 -0
  82. package/template/server/.dockerignore +66 -0
  83. package/template/server/.env.example +96 -69
  84. package/template/server/.env.production.example +90 -0
  85. package/template/server/Dockerfile +94 -0
  86. package/template/server/docker-compose.yml +82 -111
  87. package/template/server/docs/logging.md +62 -0
  88. package/template/server/eslint.config.mjs +17 -0
  89. package/template/server/package.json +68 -81
  90. package/template/server/phpmyadmin-config.php +26 -0
  91. package/template/server/postman_collection.json +666 -0
  92. package/template/server/prisma/schema.prisma +77 -93
  93. package/template/server/prisma/seed.ts +46 -142
  94. package/template/server/scripts/flush-redis.ts +41 -0
  95. package/template/server/src/app.ts +243 -71
  96. package/template/server/src/config/env.ts +67 -94
  97. package/template/server/src/libs/auth.ts +88 -0
  98. package/template/server/src/libs/cleanup.ts +35 -0
  99. package/template/server/src/libs/cookies.ts +46 -0
  100. package/template/server/src/libs/logger.ts +33 -60
  101. package/template/server/src/libs/monitoring.ts +205 -0
  102. package/template/server/src/libs/password.ts +38 -0
  103. package/template/server/src/libs/prisma.ts +68 -0
  104. package/template/server/src/libs/redis.ts +60 -79
  105. package/template/server/src/libs/requestLogger.ts +66 -0
  106. package/template/server/src/libs/storage/file-storage.service.ts +211 -0
  107. package/template/server/src/libs/storage/file-validator.ts +97 -0
  108. package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
  109. package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
  110. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
  111. package/template/server/src/modules/auth/auth.controller.ts +90 -141
  112. package/template/server/src/modules/auth/auth.repo.ts +120 -218
  113. package/template/server/src/modules/auth/auth.routes.ts +96 -83
  114. package/template/server/src/modules/auth/auth.schemas.ts +35 -137
  115. package/template/server/src/modules/auth/auth.service.ts +286 -329
  116. package/template/server/src/modules/auth/session.repo.ts +110 -0
  117. package/template/server/src/modules/users/users.controller.ts +120 -0
  118. package/template/server/src/modules/users/users.repo.ts +77 -0
  119. package/template/server/src/modules/users/users.routes.ts +89 -0
  120. package/template/server/src/modules/users/users.schemas.ts +21 -0
  121. package/template/server/src/modules/users/users.service.ts +169 -0
  122. package/template/server/src/server.ts +58 -139
  123. package/template/server/src/shared/errors/AppError.ts +21 -0
  124. package/template/server/src/shared/errors/errors.ts +43 -0
  125. package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
  126. package/template/server/src/shared/responses/successResponse.ts +17 -0
  127. package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
  128. package/template/server/src/shared/types/index.ts +26 -0
  129. package/template/server/src/test/setup.ts +74 -38
  130. package/template/server/tsconfig.json +27 -89
  131. package/template/server/uploads/avatars/.gitkeep +1 -0
  132. package/template/server/vitest.config.ts +43 -98
  133. package/template/.agent/rules/client/01-project-structure.md +0 -326
  134. package/template/.agent/rules/client/02-component-patterns.md +0 -249
  135. package/template/.agent/rules/client/03-typescript-rules.md +0 -226
  136. package/template/.agent/rules/client/04-state-management.md +0 -474
  137. package/template/.agent/rules/client/05-api-integration.md +0 -129
  138. package/template/.agent/rules/client/06-forms-validation.md +0 -129
  139. package/template/.agent/rules/client/07-common-patterns.md +0 -150
  140. package/template/.agent/rules/client/08-color-system.md +0 -93
  141. package/template/.agent/rules/client/09-security-rules.md +0 -97
  142. package/template/.agent/rules/client/10-testing-strategy.md +0 -370
  143. package/template/.agent/rules/global/ai-edit-safety.md +0 -38
  144. package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
  145. package/template/.agent/rules/server/02-general-rules.md +0 -111
  146. package/template/.agent/rules/server/03-migrations.md +0 -20
  147. package/template/.agent/rules/server/04-pagination.md +0 -130
  148. package/template/.agent/rules/server/05-project-conventions.md +0 -71
  149. package/template/.agent/rules/server/06-response-handling.md +0 -173
  150. package/template/.agent/rules/server/07-testing-strategy.md +0 -506
  151. package/template/.agent/rules/server/08-observability.md +0 -180
  152. package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
  153. package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
  154. package/template/.agent/rules/server/12-performance-optimization.md +0 -567
  155. package/template/.claude/rules/client-01-project-structure.md +0 -327
  156. package/template/.claude/rules/client-02-component-patterns.md +0 -250
  157. package/template/.claude/rules/client-03-typescript-rules.md +0 -227
  158. package/template/.claude/rules/client-04-state-management.md +0 -475
  159. package/template/.claude/rules/client-05-api-integration.md +0 -130
  160. package/template/.claude/rules/client-06-forms-validation.md +0 -130
  161. package/template/.claude/rules/client-07-common-patterns.md +0 -151
  162. package/template/.claude/rules/client-08-color-system.md +0 -94
  163. package/template/.claude/rules/client-09-security-rules.md +0 -98
  164. package/template/.claude/rules/client-10-testing-strategy.md +0 -371
  165. package/template/.claude/rules/global-ai-edit-safety.md +0 -39
  166. package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
  167. package/template/.claude/rules/server-02-general-rules.md +0 -112
  168. package/template/.claude/rules/server-03-migrations.md +0 -21
  169. package/template/.claude/rules/server-04-pagination.md +0 -131
  170. package/template/.claude/rules/server-05-project-conventions.md +0 -72
  171. package/template/.claude/rules/server-06-response-handling.md +0 -174
  172. package/template/.claude/rules/server-07-testing-strategy.md +0 -507
  173. package/template/.claude/rules/server-08-observability.md +0 -181
  174. package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
  175. package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
  176. package/template/.claude/rules/server-12-performance-optimization.md +0 -568
  177. package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
  178. package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
  179. package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
  180. package/template/.cursor/rules/client-04-state-management.mdc +0 -475
  181. package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
  182. package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
  183. package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
  184. package/template/.cursor/rules/client-08-color-system.mdc +0 -94
  185. package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
  186. package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
  187. package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
  188. package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
  189. package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
  190. package/template/.cursor/rules/server-03-migrations.mdc +0 -21
  191. package/template/.cursor/rules/server-04-pagination.mdc +0 -131
  192. package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
  193. package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
  194. package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
  195. package/template/.cursor/rules/server-08-observability.mdc +0 -181
  196. package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
  197. package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
  198. package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
  199. package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
  200. package/template/CLAUDE.md +0 -207
  201. package/template/server/.tsc-aliasrc.json +0 -13
  202. package/template/server/IMPORT_FIX_CHECKLIST.md +0 -98
  203. package/template/server/IMPORT_FIX_COMPLETE.md +0 -89
  204. package/template/server/README.md +0 -183
  205. package/template/server/REMAINING_IMPORT_FIXES.md +0 -150
  206. package/template/server/SECURITY.md +0 -190
  207. package/template/server/Tigra-API.postman_collection.json +0 -733
  208. package/template/server/biome.json +0 -42
  209. package/template/server/scripts/fix-all-imports.ps1 +0 -52
  210. package/template/server/scripts/fix-imports-reference.ps1 +0 -16
  211. package/template/server/scripts/fix-imports.mjs +0 -55
  212. package/template/server/scripts/setup-env.js +0 -50
  213. package/template/server/scripts/wait-for-db.js +0 -60
  214. package/template/server/src/hooks/request-timing.hook.ts +0 -26
  215. package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
  216. package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
  217. package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
  218. package/template/server/src/libs/db.ts +0 -76
  219. package/template/server/src/libs/error-handler.ts +0 -89
  220. package/template/server/src/libs/queue.ts +0 -79
  221. package/template/server/src/modules/admin/admin.controller.ts +0 -122
  222. package/template/server/src/modules/admin/admin.routes.ts +0 -62
  223. package/template/server/src/modules/admin/admin.schemas.ts +0 -35
  224. package/template/server/src/modules/admin/admin.service.ts +0 -167
  225. package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
  226. package/template/server/src/modules/auth/auth.service.test.ts +0 -119
  227. package/template/server/src/modules/auth/auth.types.ts +0 -97
  228. package/template/server/src/modules/resources/resources.controller.ts +0 -218
  229. package/template/server/src/modules/resources/resources.repo.ts +0 -253
  230. package/template/server/src/modules/resources/resources.routes.ts +0 -116
  231. package/template/server/src/modules/resources/resources.schemas.ts +0 -146
  232. package/template/server/src/modules/resources/resources.service.ts +0 -218
  233. package/template/server/src/modules/resources/resources.types.ts +0 -73
  234. package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
  235. package/template/server/src/plugins/security.plugin.ts +0 -21
  236. package/template/server/src/routes/health.routes.ts +0 -31
  237. package/template/server/src/types/fastify.d.ts +0 -36
  238. package/template/server/src/utils/errors.ts +0 -108
  239. package/template/server/src/utils/pagination.ts +0 -120
  240. package/template/server/src/utils/response.ts +0 -110
  241. package/template/server/src/workers/file.worker.ts +0 -106
  242. package/template/server/tsconfig.build.json +0 -30
  243. 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();