servcraft 0.1.0 → 0.1.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/.claude/settings.local.json +29 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/README.md +1070 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { logger } from '../../core/logger.js';
|
|
5
|
+
import { BadRequestError, NotFoundError } from '../../utils/errors.js';
|
|
6
|
+
import { prisma } from '../../database/prisma.js';
|
|
7
|
+
import { UploadRepository } from './upload.repository.js';
|
|
8
|
+
import type {
|
|
9
|
+
UploadConfig,
|
|
10
|
+
UploadedFile,
|
|
11
|
+
UploadOptions,
|
|
12
|
+
MultipartFile,
|
|
13
|
+
ImageTransformOptions,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
|
|
16
|
+
const defaultConfig: UploadConfig = {
|
|
17
|
+
provider: 'local',
|
|
18
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
19
|
+
allowedMimeTypes: [
|
|
20
|
+
'image/jpeg',
|
|
21
|
+
'image/png',
|
|
22
|
+
'image/gif',
|
|
23
|
+
'image/webp',
|
|
24
|
+
'application/pdf',
|
|
25
|
+
'text/plain',
|
|
26
|
+
'application/json',
|
|
27
|
+
],
|
|
28
|
+
local: {
|
|
29
|
+
uploadDir: './uploads',
|
|
30
|
+
publicPath: '/uploads',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class UploadService {
|
|
35
|
+
private config: UploadConfig;
|
|
36
|
+
private repository: UploadRepository;
|
|
37
|
+
|
|
38
|
+
constructor(config: Partial<UploadConfig> = {}) {
|
|
39
|
+
this.config = { ...defaultConfig, ...config };
|
|
40
|
+
this.repository = new UploadRepository(prisma);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async upload(file: MultipartFile, options: UploadOptions = {}): Promise<UploadedFile> {
|
|
44
|
+
// Validate mime type
|
|
45
|
+
if (!this.config.allowedMimeTypes.includes(file.mimetype)) {
|
|
46
|
+
throw new BadRequestError(
|
|
47
|
+
`File type not allowed. Allowed types: ${this.config.allowedMimeTypes.join(', ')}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const buffer = await file.toBuffer();
|
|
52
|
+
|
|
53
|
+
// Validate file size
|
|
54
|
+
if (buffer.length > this.config.maxFileSize) {
|
|
55
|
+
throw new BadRequestError(
|
|
56
|
+
`File too large. Maximum size: ${this.formatBytes(this.config.maxFileSize)}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let processedBuffer = buffer;
|
|
61
|
+
|
|
62
|
+
// Apply image transformations if requested
|
|
63
|
+
if (options.transform && this.isImage(file.mimetype)) {
|
|
64
|
+
processedBuffer = await this.transformImage(buffer, options.transform);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const uploadedFile = await this.saveFile(file, processedBuffer, options);
|
|
68
|
+
|
|
69
|
+
// Save metadata to database
|
|
70
|
+
const savedFile = await this.repository.create(uploadedFile);
|
|
71
|
+
|
|
72
|
+
logger.info({ fileId: savedFile.id, size: savedFile.size }, 'File uploaded');
|
|
73
|
+
return savedFile;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async uploadMultiple(
|
|
77
|
+
uploadFiles: MultipartFile[],
|
|
78
|
+
options: UploadOptions = {}
|
|
79
|
+
): Promise<UploadedFile[]> {
|
|
80
|
+
const results: UploadedFile[] = [];
|
|
81
|
+
for (const file of uploadFiles) {
|
|
82
|
+
const uploaded = await this.upload(file, options);
|
|
83
|
+
results.push(uploaded);
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getFile(fileId: string): Promise<UploadedFile | null> {
|
|
89
|
+
return this.repository.getById(fileId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
93
|
+
const file = await this.repository.getById(fileId);
|
|
94
|
+
if (!file) {
|
|
95
|
+
throw new NotFoundError('File');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Delete actual file from storage
|
|
99
|
+
switch (file.provider) {
|
|
100
|
+
case 'local':
|
|
101
|
+
await this.deleteLocal(file.path);
|
|
102
|
+
break;
|
|
103
|
+
case 's3':
|
|
104
|
+
await this.deleteS3(file.path);
|
|
105
|
+
break;
|
|
106
|
+
case 'cloudinary':
|
|
107
|
+
await this.deleteCloudinary(file.path);
|
|
108
|
+
break;
|
|
109
|
+
case 'gcs':
|
|
110
|
+
await this.deleteGCS(file.path);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Delete metadata from database
|
|
115
|
+
await this.repository.delete(fileId);
|
|
116
|
+
logger.info({ fileId }, 'File deleted');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getSignedUrl(fileId: string, expiresIn = 3600): Promise<string> {
|
|
120
|
+
const file = await this.repository.getById(fileId);
|
|
121
|
+
if (!file) {
|
|
122
|
+
throw new NotFoundError('File');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
switch (file.provider) {
|
|
126
|
+
case 's3':
|
|
127
|
+
return this.getS3SignedUrl(file.path, expiresIn);
|
|
128
|
+
case 'gcs':
|
|
129
|
+
return this.getGCSSignedUrl(file.path, expiresIn);
|
|
130
|
+
default:
|
|
131
|
+
return file.url;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get files by user ID
|
|
137
|
+
*/
|
|
138
|
+
async getFilesByUser(
|
|
139
|
+
userId: string,
|
|
140
|
+
options?: { limit?: number; offset?: number }
|
|
141
|
+
): Promise<UploadedFile[]> {
|
|
142
|
+
return this.repository.getByUserId(userId, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get total storage used by user
|
|
147
|
+
*/
|
|
148
|
+
async getUserStorageUsage(userId: string): Promise<{ totalSize: number; fileCount: number }> {
|
|
149
|
+
const [totalSize, fileCount] = await Promise.all([
|
|
150
|
+
this.repository.getTotalSizeByUser(userId),
|
|
151
|
+
this.repository.countByUser(userId),
|
|
152
|
+
]);
|
|
153
|
+
return { totalSize, fileCount };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Delete all files for a user
|
|
158
|
+
*/
|
|
159
|
+
async deleteUserFiles(userId: string): Promise<number> {
|
|
160
|
+
const files = await this.repository.getByUserId(userId);
|
|
161
|
+
|
|
162
|
+
// Delete actual files
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
try {
|
|
165
|
+
switch (file.provider) {
|
|
166
|
+
case 'local':
|
|
167
|
+
await this.deleteLocal(file.path);
|
|
168
|
+
break;
|
|
169
|
+
case 's3':
|
|
170
|
+
await this.deleteS3(file.path);
|
|
171
|
+
break;
|
|
172
|
+
case 'cloudinary':
|
|
173
|
+
await this.deleteCloudinary(file.path);
|
|
174
|
+
break;
|
|
175
|
+
case 'gcs':
|
|
176
|
+
await this.deleteGCS(file.path);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.warn({ fileId: file.id, error }, 'Failed to delete file from storage');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Delete all metadata records
|
|
185
|
+
return this.repository.deleteByUserId(userId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Private methods
|
|
189
|
+
private async saveFile(
|
|
190
|
+
file: MultipartFile,
|
|
191
|
+
buffer: Buffer,
|
|
192
|
+
options: UploadOptions
|
|
193
|
+
): Promise<UploadedFile> {
|
|
194
|
+
const id = randomUUID();
|
|
195
|
+
const ext = path.extname(file.filename);
|
|
196
|
+
const filename = options.filename || `${id}${ext}`;
|
|
197
|
+
const folder = options.folder || '';
|
|
198
|
+
|
|
199
|
+
switch (this.config.provider) {
|
|
200
|
+
case 'local':
|
|
201
|
+
return this.saveLocal(id, file, buffer, filename, folder);
|
|
202
|
+
case 's3':
|
|
203
|
+
return this.saveS3(id, file, buffer, filename, folder, options.isPublic);
|
|
204
|
+
case 'cloudinary':
|
|
205
|
+
return this.saveCloudinary(id, file, buffer, filename, folder);
|
|
206
|
+
case 'gcs':
|
|
207
|
+
return this.saveGCS(id, file, buffer, filename, folder, options.isPublic);
|
|
208
|
+
default:
|
|
209
|
+
return this.saveLocal(id, file, buffer, filename, folder);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Local storage
|
|
214
|
+
private async saveLocal(
|
|
215
|
+
id: string,
|
|
216
|
+
file: MultipartFile,
|
|
217
|
+
buffer: Buffer,
|
|
218
|
+
filename: string,
|
|
219
|
+
folder: string
|
|
220
|
+
): Promise<UploadedFile> {
|
|
221
|
+
const uploadDir = path.join(this.config.local!.uploadDir, folder);
|
|
222
|
+
await fs.mkdir(uploadDir, { recursive: true });
|
|
223
|
+
|
|
224
|
+
const filePath = path.join(uploadDir, filename);
|
|
225
|
+
await fs.writeFile(filePath, buffer);
|
|
226
|
+
|
|
227
|
+
const publicPath = path.join(this.config.local!.publicPath, folder, filename);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
id,
|
|
231
|
+
originalName: file.filename,
|
|
232
|
+
filename,
|
|
233
|
+
mimetype: file.mimetype,
|
|
234
|
+
size: buffer.length,
|
|
235
|
+
path: filePath,
|
|
236
|
+
url: publicPath,
|
|
237
|
+
provider: 'local',
|
|
238
|
+
createdAt: new Date(),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async deleteLocal(filePath: string): Promise<void> {
|
|
243
|
+
await fs.unlink(filePath).catch(() => {});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// S3 storage
|
|
247
|
+
private async saveS3(
|
|
248
|
+
id: string,
|
|
249
|
+
file: MultipartFile,
|
|
250
|
+
buffer: Buffer,
|
|
251
|
+
filename: string,
|
|
252
|
+
folder: string,
|
|
253
|
+
isPublic = false
|
|
254
|
+
): Promise<UploadedFile> {
|
|
255
|
+
const config = this.config.s3!;
|
|
256
|
+
const key = folder ? `${folder}/${filename}` : filename;
|
|
257
|
+
|
|
258
|
+
// Using fetch for S3 upload (simplified - in production use @aws-sdk/client-s3)
|
|
259
|
+
const url = `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
260
|
+
|
|
261
|
+
const response = await fetch(url, {
|
|
262
|
+
method: 'PUT',
|
|
263
|
+
headers: {
|
|
264
|
+
'Content-Type': file.mimetype,
|
|
265
|
+
'Content-Length': buffer.length.toString(),
|
|
266
|
+
'x-amz-acl': isPublic ? 'public-read' : 'private',
|
|
267
|
+
},
|
|
268
|
+
body: new Uint8Array(buffer),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
throw new Error(`S3 upload failed: ${response.statusText}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
id,
|
|
277
|
+
originalName: file.filename,
|
|
278
|
+
filename,
|
|
279
|
+
mimetype: file.mimetype,
|
|
280
|
+
size: buffer.length,
|
|
281
|
+
path: key,
|
|
282
|
+
url: isPublic ? url : '',
|
|
283
|
+
provider: 's3',
|
|
284
|
+
bucket: config.bucket,
|
|
285
|
+
createdAt: new Date(),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async deleteS3(key: string): Promise<void> {
|
|
290
|
+
const config = this.config.s3!;
|
|
291
|
+
const url = `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
292
|
+
await fetch(url, { method: 'DELETE' });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async getS3SignedUrl(key: string, _expiresIn: number): Promise<string> {
|
|
296
|
+
const config = this.config.s3!;
|
|
297
|
+
// Simplified - in production use @aws-sdk/s3-request-presigner
|
|
298
|
+
return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Cloudinary storage
|
|
302
|
+
private async saveCloudinary(
|
|
303
|
+
id: string,
|
|
304
|
+
file: MultipartFile,
|
|
305
|
+
buffer: Buffer,
|
|
306
|
+
filename: string,
|
|
307
|
+
folder: string
|
|
308
|
+
): Promise<UploadedFile> {
|
|
309
|
+
const config = this.config.cloudinary!;
|
|
310
|
+
const uploadFolder = config.folder ? `${config.folder}/${folder}` : folder;
|
|
311
|
+
|
|
312
|
+
const formData = new FormData();
|
|
313
|
+
formData.append('file', new Blob([new Uint8Array(buffer)], { type: file.mimetype }));
|
|
314
|
+
formData.append('api_key', config.apiKey);
|
|
315
|
+
formData.append('folder', uploadFolder);
|
|
316
|
+
formData.append('public_id', path.parse(filename).name);
|
|
317
|
+
|
|
318
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
319
|
+
formData.append('timestamp', timestamp.toString());
|
|
320
|
+
|
|
321
|
+
// Generate signature (simplified - use cloudinary SDK in production)
|
|
322
|
+
const response = await fetch(
|
|
323
|
+
`https://api.cloudinary.com/v1_1/${config.cloudName}/auto/upload`,
|
|
324
|
+
{ method: 'POST', body: formData }
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const result = (await response.json()) as { secure_url: string; public_id: string };
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
id,
|
|
331
|
+
originalName: file.filename,
|
|
332
|
+
filename,
|
|
333
|
+
mimetype: file.mimetype,
|
|
334
|
+
size: buffer.length,
|
|
335
|
+
path: result.public_id,
|
|
336
|
+
url: result.secure_url,
|
|
337
|
+
provider: 'cloudinary',
|
|
338
|
+
createdAt: new Date(),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async deleteCloudinary(publicId: string): Promise<void> {
|
|
343
|
+
const config = this.config.cloudinary!;
|
|
344
|
+
const formData = new FormData();
|
|
345
|
+
formData.append('api_key', config.apiKey);
|
|
346
|
+
formData.append('public_id', publicId);
|
|
347
|
+
|
|
348
|
+
await fetch(`https://api.cloudinary.com/v1_1/${config.cloudName}/image/destroy`, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
body: formData,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// GCS storage
|
|
355
|
+
private async saveGCS(
|
|
356
|
+
id: string,
|
|
357
|
+
file: MultipartFile,
|
|
358
|
+
buffer: Buffer,
|
|
359
|
+
filename: string,
|
|
360
|
+
folder: string,
|
|
361
|
+
isPublic = false
|
|
362
|
+
): Promise<UploadedFile> {
|
|
363
|
+
const config = this.config.gcs!;
|
|
364
|
+
const objectName = folder ? `${folder}/${filename}` : filename;
|
|
365
|
+
|
|
366
|
+
const url = `https://storage.googleapis.com/upload/storage/v1/b/${config.bucket}/o?uploadType=media&name=${objectName}`;
|
|
367
|
+
|
|
368
|
+
const response = await fetch(url, {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
headers: { 'Content-Type': file.mimetype },
|
|
371
|
+
body: new Uint8Array(buffer),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
throw new Error(`GCS upload failed: ${response.statusText}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const publicUrl = isPublic
|
|
379
|
+
? `https://storage.googleapis.com/${config.bucket}/${objectName}`
|
|
380
|
+
: '';
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
id,
|
|
384
|
+
originalName: file.filename,
|
|
385
|
+
filename,
|
|
386
|
+
mimetype: file.mimetype,
|
|
387
|
+
size: buffer.length,
|
|
388
|
+
path: objectName,
|
|
389
|
+
url: publicUrl,
|
|
390
|
+
provider: 'gcs',
|
|
391
|
+
bucket: config.bucket,
|
|
392
|
+
createdAt: new Date(),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async deleteGCS(objectName: string): Promise<void> {
|
|
397
|
+
const config = this.config.gcs!;
|
|
398
|
+
const url = `https://storage.googleapis.com/storage/v1/b/${config.bucket}/o/${encodeURIComponent(objectName)}`;
|
|
399
|
+
await fetch(url, { method: 'DELETE' });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async getGCSSignedUrl(objectName: string, _expiresIn: number): Promise<string> {
|
|
403
|
+
const config = this.config.gcs!;
|
|
404
|
+
return `https://storage.googleapis.com/${config.bucket}/${objectName}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Image transformation (simplified - use sharp in production)
|
|
408
|
+
private async transformImage(buffer: Buffer, options: ImageTransformOptions): Promise<Buffer> {
|
|
409
|
+
// This is a placeholder - in production, use the 'sharp' package
|
|
410
|
+
// Example with sharp:
|
|
411
|
+
// import sharp from 'sharp';
|
|
412
|
+
// let image = sharp(buffer);
|
|
413
|
+
// if (options.width || options.height) {
|
|
414
|
+
// image = image.resize(options.width, options.height, { fit: options.fit });
|
|
415
|
+
// }
|
|
416
|
+
// if (options.grayscale) image = image.grayscale();
|
|
417
|
+
// if (options.blur) image = image.blur(options.blur);
|
|
418
|
+
// if (options.format) image = image.toFormat(options.format, { quality: options.quality });
|
|
419
|
+
// return image.toBuffer();
|
|
420
|
+
|
|
421
|
+
logger.debug({ options }, 'Image transformation requested (using placeholder)');
|
|
422
|
+
return buffer;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private isImage(mimetype: string): boolean {
|
|
426
|
+
return mimetype.startsWith('image/');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private formatBytes(bytes: number): string {
|
|
430
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
431
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
432
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let uploadService: UploadService | null = null;
|
|
437
|
+
|
|
438
|
+
export function getUploadService(): UploadService {
|
|
439
|
+
if (!uploadService) {
|
|
440
|
+
uploadService = new UploadService();
|
|
441
|
+
}
|
|
442
|
+
return uploadService;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function createUploadService(config: Partial<UploadConfig>): UploadService {
|
|
446
|
+
uploadService = new UploadService(config);
|
|
447
|
+
return uploadService;
|
|
448
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import { logger } from '../../core/logger.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { createUserService } from './user.service.js';
|
|
4
|
+
import { createUserController } from './user.controller.js';
|
|
5
|
+
import { createUserRepository } from './user.repository.js';
|
|
6
6
|
import { registerUserRoutes } from './user.routes.js';
|
|
7
7
|
import type { AuthService } from '../auth/auth.service.js';
|
|
8
8
|
|
|
@@ -6,6 +6,14 @@ import { parsePaginationParams } from '../../utils/pagination.js';
|
|
|
6
6
|
import { validateBody, validateQuery } from '../validation/validator.js';
|
|
7
7
|
import type { AuthenticatedRequest } from '../auth/types.js';
|
|
8
8
|
import { ForbiddenError } from '../../utils/errors.js';
|
|
9
|
+
import type { User } from './types.js';
|
|
10
|
+
|
|
11
|
+
// Helper to remove password from user object
|
|
12
|
+
function omitPassword(user: User): Omit<User, 'password'> {
|
|
13
|
+
const { password, ...userData } = user;
|
|
14
|
+
void password; // Explicitly mark as intentionally unused
|
|
15
|
+
return userData;
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
export class UserController {
|
|
11
19
|
constructor(private userService: UserService) {}
|
|
@@ -39,8 +47,7 @@ export class UserController {
|
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
// Remove password from response
|
|
42
|
-
|
|
43
|
-
success(reply, userData);
|
|
50
|
+
success(reply, omitPassword(user));
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
async update(
|
|
@@ -50,8 +57,7 @@ export class UserController {
|
|
|
50
57
|
const data = validateBody(updateUserSchema, request.body);
|
|
51
58
|
const user = await this.userService.update(request.params.id, data);
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
success(reply, userData);
|
|
60
|
+
success(reply, omitPassword(user));
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
async delete(
|
|
@@ -80,7 +86,7 @@ export class UserController {
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
const user = await this.userService.suspend(request.params.id);
|
|
83
|
-
const
|
|
89
|
+
const userData = omitPassword(user);
|
|
84
90
|
success(reply, userData);
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -95,7 +101,7 @@ export class UserController {
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
const user = await this.userService.ban(request.params.id);
|
|
98
|
-
const
|
|
104
|
+
const userData = omitPassword(user);
|
|
99
105
|
success(reply, userData);
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -104,7 +110,7 @@ export class UserController {
|
|
|
104
110
|
reply: FastifyReply
|
|
105
111
|
): Promise<void> {
|
|
106
112
|
const user = await this.userService.activate(request.params.id);
|
|
107
|
-
const
|
|
113
|
+
const userData = omitPassword(user);
|
|
108
114
|
success(reply, userData);
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -120,7 +126,7 @@ export class UserController {
|
|
|
120
126
|
});
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
const
|
|
129
|
+
const userData = omitPassword(user);
|
|
124
130
|
success(reply, userData);
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -129,7 +135,7 @@ export class UserController {
|
|
|
129
135
|
const data = validateBody(updateProfileSchema, request.body);
|
|
130
136
|
|
|
131
137
|
const user = await this.userService.update(authRequest.user.id, data);
|
|
132
|
-
const
|
|
138
|
+
const userData = omitPassword(user);
|
|
133
139
|
success(reply, userData);
|
|
134
140
|
}
|
|
135
141
|
}
|