create-forgeon 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/add-options.test.mjs +5 -2
- package/src/cli/options.test.mjs +1 -0
- package/src/cli/prompt-select.test.mjs +1 -0
- package/src/core/docs.test.mjs +1 -0
- package/src/core/scaffold.test.mjs +1 -0
- package/src/core/validate.test.mjs +1 -0
- package/src/modules/accounts.mjs +416 -0
- package/src/modules/db-prisma.mjs +5 -9
- package/src/modules/dependencies.test.mjs +71 -29
- package/src/modules/executor.mjs +3 -2
- package/src/modules/executor.test.mjs +521 -477
- package/src/modules/files-access.mjs +9 -7
- package/src/modules/files-image.mjs +9 -7
- package/src/modules/files-local.mjs +15 -6
- package/src/modules/files-quotas.mjs +8 -6
- package/src/modules/files-s3.mjs +17 -6
- package/src/modules/files.mjs +21 -21
- package/src/modules/idempotency.test.mjs +13 -7
- package/src/modules/probes.test.mjs +4 -2
- package/src/modules/queue.mjs +9 -6
- package/src/modules/rate-limit.mjs +14 -10
- package/src/modules/rbac.mjs +12 -11
- package/src/modules/registry.mjs +22 -35
- package/src/modules/scheduler.mjs +9 -6
- package/src/modules/shared/files-runtime-wiring.mjs +81 -0
- package/src/modules/shared/patch-utils.mjs +29 -1
- package/src/modules/sync-integrations.mjs +102 -422
- package/src/modules/sync-integrations.test.mjs +32 -111
- package/src/run-add-module.test.mjs +1 -0
- package/templates/base/scripts/forgeon-sync-integrations.mjs +65 -325
- package/templates/module-fragments/{jwt-auth → accounts}/00_title.md +2 -1
- package/templates/module-fragments/{jwt-auth → accounts}/10_overview.md +5 -5
- package/templates/module-fragments/accounts/20_scope.md +29 -0
- package/templates/module-fragments/accounts/90_status_implemented.md +8 -0
- package/templates/module-fragments/accounts/90_status_planned.md +7 -0
- package/templates/module-fragments/rbac/30_what_it_adds.md +3 -2
- package/templates/module-fragments/rbac/40_how_it_works.md +2 -1
- package/templates/module-fragments/rbac/50_how_to_use.md +2 -1
- package/templates/module-fragments/swagger/20_scope.md +2 -1
- package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +97 -0
- package/templates/module-presets/accounts/apps/api/src/accounts/forgeon-accounts-db-prisma.module.ts +17 -0
- package/templates/module-presets/accounts/apps/api/src/accounts/prisma-accounts-persistence.store.ts +332 -0
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/package.json +5 -5
- package/templates/module-presets/accounts/packages/accounts-api/src/accounts-email.port.ts +13 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/accounts-persistence.port.ts +67 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/accounts-rbac.port.ts +14 -0
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.loader.ts +7 -7
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.service.ts +7 -7
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +318 -0
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-env.schema.ts +4 -4
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-jwt.service.ts +58 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-password.service.ts +21 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +93 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +48 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.types.ts +17 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/change-password.dto.ts +13 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/confirm-password-reset.dto.ts +12 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/index.ts +10 -0
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/dto/login.dto.ts +1 -1
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/dto/refresh.dto.ts +1 -1
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/register.dto.ts +23 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/request-password-reset.dto.ts +7 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user-profile.dto.ts +16 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user-settings.dto.ts +16 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user.dto.ts +8 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/dto/verify-email.dto.ts +8 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +82 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +24 -0
- package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/jwt.strategy.ts +3 -3
- package/templates/module-presets/accounts/packages/accounts-api/src/owner-access.guard.ts +39 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/users-config.ts +13 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/users.controller.ts +65 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/users.service.ts +87 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/users.types.ts +65 -0
- package/templates/module-presets/{jwt-auth/packages/auth-contracts → accounts/packages/accounts-contracts}/package.json +1 -1
- package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +119 -0
- package/templates/module-presets/db-prisma/apps/api/prisma/seed.ts +40 -19
- package/templates/module-presets/files/apps/api/src/files/forgeon-files-db-prisma.module.ts +17 -0
- package/templates/module-presets/files/apps/api/src/files/prisma-files-persistence.store.ts +164 -0
- package/templates/module-presets/files/packages/files/package.json +1 -2
- package/templates/module-presets/files/packages/files/src/files.ports.ts +107 -0
- package/templates/module-presets/files/packages/files/src/files.service.ts +81 -395
- package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +126 -2
- package/templates/module-presets/files/packages/files/src/index.ts +2 -1
- package/templates/module-presets/files-local/packages/files-local/src/forgeon-files-local-storage.module.ts +18 -0
- package/templates/module-presets/files-local/packages/files-local/src/index.ts +2 -0
- package/templates/module-presets/files-local/packages/files-local/src/local-files-storage.adapter.ts +53 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/forgeon-files-s3-storage.module.ts +18 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/index.ts +2 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/s3-files-storage.adapter.ts +130 -0
- package/src/modules/jwt-auth.mjs +0 -271
- package/templates/module-fragments/jwt-auth/20_scope.md +0 -19
- package/templates/module-fragments/jwt-auth/90_status_implemented.md +0 -8
- package/templates/module-fragments/jwt-auth/90_status_planned.md +0 -3
- package/templates/module-presets/jwt-auth/apps/api/prisma/migrations/0002_auth_refresh_token_hash/migration.sql +0 -3
- package/templates/module-presets/jwt-auth/apps/api/src/auth/prisma-auth-refresh-token.store.ts +0 -36
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-refresh-token.store.ts +0 -23
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +0 -71
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +0 -175
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.types.ts +0 -6
- package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/index.ts +0 -2
- package/templates/module-presets/jwt-auth/packages/auth-api/src/forgeon-auth.module.ts +0 -47
- package/templates/module-presets/jwt-auth/packages/auth-api/src/index.ts +0 -12
- package/templates/module-presets/jwt-auth/packages/auth-contracts/src/index.ts +0 -47
- /package/templates/module-presets/{jwt-auth/packages/auth-api/src/jwt-auth.guard.ts → accounts/packages/accounts-api/src/access-token.guard.ts} +0 -0
- /package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.module.ts +0 -0
- /package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/tsconfig.json +0 -0
- /package/templates/module-presets/{jwt-auth/packages/auth-contracts → accounts/packages/accounts-contracts}/tsconfig.json +0 -0
|
@@ -1,41 +1,28 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { promises as fsPromises } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
5
3
|
import { Readable } from 'node:stream';
|
|
6
4
|
import {
|
|
7
5
|
BadRequestException,
|
|
8
|
-
|
|
6
|
+
Inject,
|
|
9
7
|
Injectable,
|
|
8
|
+
InternalServerErrorException,
|
|
10
9
|
NotFoundException,
|
|
11
10
|
ServiceUnavailableException,
|
|
12
11
|
} from '@nestjs/common';
|
|
13
|
-
import { ConfigService } from '@nestjs/config';
|
|
14
|
-
import { PrismaService } from '@forgeon/db-prisma';
|
|
15
12
|
import { FilesConfigService } from './files-config.service';
|
|
13
|
+
import {
|
|
14
|
+
FILES_PERSISTENCE_PORT,
|
|
15
|
+
FILES_STORAGE_ADAPTER,
|
|
16
|
+
} from './files.ports';
|
|
17
|
+
import type {
|
|
18
|
+
FilesBlobRecord,
|
|
19
|
+
FilesBlobRef,
|
|
20
|
+
FilesPersistencePort,
|
|
21
|
+
FilesRecordAggregate,
|
|
22
|
+
FilesStorageAdapter,
|
|
23
|
+
} from './files.ports';
|
|
16
24
|
import type { FileRecordDto, FileVariantKey, StoredFileInput } from './files.types';
|
|
17
25
|
|
|
18
|
-
type S3ModuleLike = {
|
|
19
|
-
S3Client: new (config: Record<string, unknown>) => {
|
|
20
|
-
send: (command: unknown) => Promise<{
|
|
21
|
-
Body?: unknown;
|
|
22
|
-
}>;
|
|
23
|
-
};
|
|
24
|
-
PutObjectCommand: new (input: Record<string, unknown>) => unknown;
|
|
25
|
-
GetObjectCommand: new (input: Record<string, unknown>) => unknown;
|
|
26
|
-
DeleteObjectCommand: new (input: Record<string, unknown>) => unknown;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
type BlobRef = {
|
|
30
|
-
id: string;
|
|
31
|
-
hash: string;
|
|
32
|
-
size: number;
|
|
33
|
-
mimeType: string;
|
|
34
|
-
storageDriver: string;
|
|
35
|
-
storageKey: string;
|
|
36
|
-
created: boolean;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
26
|
type PrismaLikeError = {
|
|
40
27
|
code?: unknown;
|
|
41
28
|
};
|
|
@@ -56,17 +43,11 @@ type PersistedVariant = {
|
|
|
56
43
|
|
|
57
44
|
@Injectable()
|
|
58
45
|
export class FilesService {
|
|
59
|
-
private s3Client:
|
|
60
|
-
| {
|
|
61
|
-
send: (command: unknown) => Promise<{
|
|
62
|
-
Body?: unknown;
|
|
63
|
-
}>;
|
|
64
|
-
}
|
|
65
|
-
| null = null;
|
|
66
|
-
|
|
67
46
|
constructor(
|
|
68
|
-
|
|
69
|
-
private readonly
|
|
47
|
+
@Inject(FILES_PERSISTENCE_PORT)
|
|
48
|
+
private readonly persistence: FilesPersistencePort,
|
|
49
|
+
@Inject(FILES_STORAGE_ADAPTER)
|
|
50
|
+
private readonly storageAdapter: FilesStorageAdapter,
|
|
70
51
|
private readonly filesConfigService: FilesConfigService,
|
|
71
52
|
) {}
|
|
72
53
|
|
|
@@ -88,19 +69,17 @@ export class FilesService {
|
|
|
88
69
|
createdBlobIds.push(originalBlob.id);
|
|
89
70
|
}
|
|
90
71
|
|
|
91
|
-
const record = await this.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
createdById: input.createdById ?? null,
|
|
103
|
-
},
|
|
72
|
+
const record = await this.persistence.createFileRecord({
|
|
73
|
+
publicId: this.generatePublicId(),
|
|
74
|
+
storageKey: originalBlob.storageKey,
|
|
75
|
+
originalName: input.originalName,
|
|
76
|
+
mimeType: preparedOriginal.mimeType,
|
|
77
|
+
size: preparedOriginal.size,
|
|
78
|
+
storageDriver: originalBlob.storageDriver,
|
|
79
|
+
ownerType: input.ownerType ?? 'system',
|
|
80
|
+
ownerId: input.ownerId ?? null,
|
|
81
|
+
visibility: input.visibility ?? 'private',
|
|
82
|
+
createdById: input.createdById ?? null,
|
|
104
83
|
});
|
|
105
84
|
recordId = record.id;
|
|
106
85
|
|
|
@@ -131,8 +110,8 @@ export class FilesService {
|
|
|
131
110
|
persistedVariantBlobIds.push(previewBlob.id);
|
|
132
111
|
}
|
|
133
112
|
|
|
134
|
-
await this.
|
|
135
|
-
|
|
113
|
+
await this.persistence.createVariants(
|
|
114
|
+
persistedVariants.map((item, index) => ({
|
|
136
115
|
fileId: record.id,
|
|
137
116
|
variantKey: item.variantKey,
|
|
138
117
|
blobId: persistedVariantBlobIds[index],
|
|
@@ -140,12 +119,12 @@ export class FilesService {
|
|
|
140
119
|
size: item.size,
|
|
141
120
|
status: item.status,
|
|
142
121
|
})),
|
|
143
|
-
|
|
122
|
+
);
|
|
144
123
|
|
|
145
124
|
return this.getByPublicId(record.publicId);
|
|
146
125
|
} catch (error) {
|
|
147
126
|
if (recordId) {
|
|
148
|
-
await this.
|
|
127
|
+
await this.persistence.deleteFileRecordById(recordId).catch(() => undefined);
|
|
149
128
|
}
|
|
150
129
|
await this.cleanupCreatedBlobs(createdBlobIds);
|
|
151
130
|
throw error;
|
|
@@ -165,33 +144,16 @@ export class FilesService {
|
|
|
165
144
|
}
|
|
166
145
|
|
|
167
146
|
async deleteByPublicId(publicId: string): Promise<{ deleted: boolean }> {
|
|
168
|
-
const record = await this.
|
|
169
|
-
where: { publicId },
|
|
170
|
-
include: {
|
|
171
|
-
variants: {
|
|
172
|
-
select: {
|
|
173
|
-
blobId: true,
|
|
174
|
-
blob: {
|
|
175
|
-
select: {
|
|
176
|
-
storageDriver: true,
|
|
177
|
-
storageKey: true,
|
|
178
|
-
},
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
});
|
|
147
|
+
const record = await this.persistence.findFileRecordForDelete(publicId);
|
|
184
148
|
if (!record) {
|
|
185
149
|
throw new NotFoundException('File not found');
|
|
186
150
|
}
|
|
187
151
|
|
|
188
|
-
const blobIds = record.variants.map((variant) => variant.blobId);
|
|
189
|
-
await this.
|
|
190
|
-
where: { publicId },
|
|
191
|
-
});
|
|
152
|
+
const blobIds = (record.variants ?? []).map((variant) => variant.blobId);
|
|
153
|
+
await this.persistence.deleteFileRecordByPublicId(publicId);
|
|
192
154
|
await this.cleanupReferencedBlobs(blobIds);
|
|
193
155
|
|
|
194
|
-
if (record.variants.length === 0) {
|
|
156
|
+
if ((record.variants ?? []).length === 0) {
|
|
195
157
|
await this.deleteStoredContent(record.storageDriver, record.storageKey).catch(() => undefined);
|
|
196
158
|
}
|
|
197
159
|
|
|
@@ -199,16 +161,7 @@ export class FilesService {
|
|
|
199
161
|
}
|
|
200
162
|
|
|
201
163
|
async getByPublicId(publicId: string): Promise<FileRecordDto> {
|
|
202
|
-
const record = await this.
|
|
203
|
-
where: { publicId },
|
|
204
|
-
include: {
|
|
205
|
-
variants: {
|
|
206
|
-
select: {
|
|
207
|
-
variantKey: true,
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
});
|
|
164
|
+
const record = await this.persistence.findFileRecordWithVariantKeys(publicId);
|
|
212
165
|
if (!record) {
|
|
213
166
|
throw new NotFoundException('File not found');
|
|
214
167
|
}
|
|
@@ -216,23 +169,7 @@ export class FilesService {
|
|
|
216
169
|
}
|
|
217
170
|
|
|
218
171
|
async getOwnerUsage(ownerType: string, ownerId: string): Promise<{ filesCount: number; totalBytes: number }> {
|
|
219
|
-
|
|
220
|
-
where: {
|
|
221
|
-
ownerType,
|
|
222
|
-
ownerId,
|
|
223
|
-
},
|
|
224
|
-
_count: {
|
|
225
|
-
_all: true,
|
|
226
|
-
},
|
|
227
|
-
_sum: {
|
|
228
|
-
size: true,
|
|
229
|
-
},
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
filesCount: aggregate._count._all ?? 0,
|
|
234
|
-
totalBytes: aggregate._sum.size ?? 0,
|
|
235
|
-
};
|
|
172
|
+
return this.persistence.countOwnerUsage(ownerType, ownerId);
|
|
236
173
|
}
|
|
237
174
|
|
|
238
175
|
async openDownload(publicId: string, variant: FileVariantKey = 'original'): Promise<{
|
|
@@ -240,69 +177,36 @@ export class FilesService {
|
|
|
240
177
|
mimeType: string;
|
|
241
178
|
fileName: string;
|
|
242
179
|
}> {
|
|
243
|
-
const record = await this.
|
|
244
|
-
where: { publicId },
|
|
245
|
-
include: {
|
|
246
|
-
variants: {
|
|
247
|
-
select: {
|
|
248
|
-
variantKey: true,
|
|
249
|
-
blob: {
|
|
250
|
-
select: {
|
|
251
|
-
storageDriver: true,
|
|
252
|
-
storageKey: true,
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
mimeType: true,
|
|
256
|
-
size: true,
|
|
257
|
-
},
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
});
|
|
180
|
+
const record = await this.persistence.findFileRecordForDownload(publicId);
|
|
261
181
|
if (!record) {
|
|
262
182
|
throw new NotFoundException('File not found');
|
|
263
183
|
}
|
|
264
184
|
|
|
265
185
|
const selectedVariant =
|
|
266
|
-
record.variants
|
|
186
|
+
record.variants?.find((item) => item.variantKey === variant) ??
|
|
267
187
|
(variant === 'original'
|
|
268
188
|
? {
|
|
269
189
|
variantKey: 'original',
|
|
190
|
+
blobId: 'original',
|
|
270
191
|
blob: {
|
|
271
192
|
storageDriver: record.storageDriver,
|
|
272
193
|
storageKey: record.storageKey,
|
|
273
194
|
},
|
|
274
195
|
mimeType: record.mimeType,
|
|
275
196
|
size: record.size,
|
|
197
|
+
status: 'ready',
|
|
276
198
|
}
|
|
277
199
|
: null);
|
|
278
200
|
|
|
279
|
-
if (!selectedVariant) {
|
|
201
|
+
if (!selectedVariant?.blob) {
|
|
280
202
|
throw new NotFoundException('File variant not found');
|
|
281
203
|
}
|
|
282
204
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
return {
|
|
290
|
-
stream: fs.createReadStream(absolutePath),
|
|
291
|
-
mimeType: selectedVariant.mimeType,
|
|
292
|
-
fileName: this.buildVariantFileName(record.originalName, variant, selectedVariant.mimeType),
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
case 's3': {
|
|
296
|
-
const stream = await this.openS3(selectedVariant.blob.storageKey);
|
|
297
|
-
return {
|
|
298
|
-
stream,
|
|
299
|
-
mimeType: selectedVariant.mimeType,
|
|
300
|
-
fileName: this.buildVariantFileName(record.originalName, variant, selectedVariant.mimeType),
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
default:
|
|
304
|
-
throw new ServiceUnavailableException('Unknown files storage driver');
|
|
305
|
-
}
|
|
205
|
+
return {
|
|
206
|
+
stream: await this.openStoredContent(selectedVariant.blob.storageDriver, selectedVariant.blob.storageKey),
|
|
207
|
+
mimeType: selectedVariant.mimeType,
|
|
208
|
+
fileName: this.buildVariantFileName(record.originalName, variant, selectedVariant.mimeType),
|
|
209
|
+
};
|
|
306
210
|
}
|
|
307
211
|
|
|
308
212
|
async getVariantsProbeStatus(): Promise<{
|
|
@@ -349,18 +253,11 @@ export class FilesService {
|
|
|
349
253
|
private async store(buffer: Buffer, originalName: string): Promise<{ storageKey: string }> {
|
|
350
254
|
const extension = path.extname(originalName).toLowerCase();
|
|
351
255
|
const fileName = `${Date.now()}-${crypto.randomUUID()}${extension}`;
|
|
352
|
-
|
|
353
|
-
case 'local':
|
|
354
|
-
return this.storeLocal(buffer, fileName);
|
|
355
|
-
case 's3':
|
|
356
|
-
return this.storeS3(buffer, fileName);
|
|
357
|
-
default:
|
|
358
|
-
throw new ServiceUnavailableException('Unknown files storage driver');
|
|
359
|
-
}
|
|
256
|
+
return this.storageAdapter.put(buffer, fileName);
|
|
360
257
|
}
|
|
361
258
|
|
|
362
|
-
private async getOrCreateBlob(input: PreparedStoredFile, dedupe: boolean): Promise<
|
|
363
|
-
const storageDriver = this.
|
|
259
|
+
private async getOrCreateBlob(input: PreparedStoredFile, dedupe: boolean): Promise<FilesBlobRef> {
|
|
260
|
+
const storageDriver = this.storageAdapter.driver;
|
|
364
261
|
const hash = this.computeContentHash(input.buffer);
|
|
365
262
|
|
|
366
263
|
if (dedupe) {
|
|
@@ -372,14 +269,12 @@ export class FilesService {
|
|
|
372
269
|
|
|
373
270
|
const stored = await this.store(input.buffer, input.fileName);
|
|
374
271
|
try {
|
|
375
|
-
const created = await this.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
storageKey: stored.storageKey,
|
|
382
|
-
},
|
|
272
|
+
const created = await this.persistence.createBlob({
|
|
273
|
+
hash,
|
|
274
|
+
size: input.size,
|
|
275
|
+
mimeType: input.mimeType,
|
|
276
|
+
storageDriver,
|
|
277
|
+
storageKey: stored.storageKey,
|
|
383
278
|
});
|
|
384
279
|
|
|
385
280
|
return {
|
|
@@ -425,197 +320,22 @@ export class FilesService {
|
|
|
425
320
|
return false;
|
|
426
321
|
}
|
|
427
322
|
|
|
428
|
-
private async
|
|
429
|
-
|
|
430
|
-
case 'local':
|
|
431
|
-
await this.deleteLocal(storageKey);
|
|
432
|
-
return;
|
|
433
|
-
case 's3':
|
|
434
|
-
await this.deleteS3(storageKey);
|
|
435
|
-
return;
|
|
436
|
-
default:
|
|
437
|
-
throw new ServiceUnavailableException('Unknown files storage driver');
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
private async storeLocal(buffer: Buffer, storageKey: string): Promise<{ storageKey: string }> {
|
|
442
|
-
const rootDir = this.resolveLocalRootDir();
|
|
443
|
-
const absoluteRoot = path.resolve(process.cwd(), rootDir);
|
|
444
|
-
const absolutePath = path.join(absoluteRoot, storageKey);
|
|
445
|
-
|
|
446
|
-
await fsPromises.mkdir(absoluteRoot, { recursive: true });
|
|
447
|
-
await fsPromises.writeFile(absolutePath, buffer);
|
|
448
|
-
|
|
449
|
-
return { storageKey };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private async storeS3(buffer: Buffer, storageKey: string): Promise<{ storageKey: string }> {
|
|
453
|
-
const { PutObjectCommand } = await this.loadS3Module();
|
|
454
|
-
const client = await this.getS3Client();
|
|
455
|
-
const config = this.resolveS3Config();
|
|
456
|
-
|
|
457
|
-
await client.send(
|
|
458
|
-
new PutObjectCommand({
|
|
459
|
-
Bucket: config.bucket,
|
|
460
|
-
Key: storageKey,
|
|
461
|
-
Body: buffer,
|
|
462
|
-
}),
|
|
463
|
-
);
|
|
464
|
-
|
|
465
|
-
return { storageKey };
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
private async openS3(storageKey: string): Promise<Readable> {
|
|
469
|
-
const { GetObjectCommand } = await this.loadS3Module();
|
|
470
|
-
const client = await this.getS3Client();
|
|
471
|
-
const config = this.resolveS3Config();
|
|
472
|
-
|
|
473
|
-
const response = await client.send(
|
|
474
|
-
new GetObjectCommand({
|
|
475
|
-
Bucket: config.bucket,
|
|
476
|
-
Key: storageKey,
|
|
477
|
-
}),
|
|
478
|
-
);
|
|
479
|
-
if (!response.Body) {
|
|
480
|
-
throw new NotFoundException('File content not found');
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return this.toNodeReadable(response.Body);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
private async deleteS3(storageKey: string): Promise<void> {
|
|
487
|
-
const { DeleteObjectCommand } = await this.loadS3Module();
|
|
488
|
-
const client = await this.getS3Client();
|
|
489
|
-
const config = this.resolveS3Config();
|
|
490
|
-
await client.send(
|
|
491
|
-
new DeleteObjectCommand({
|
|
492
|
-
Bucket: config.bucket,
|
|
493
|
-
Key: storageKey,
|
|
494
|
-
}),
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
private async deleteLocal(storageKey: string): Promise<void> {
|
|
499
|
-
const rootDir = this.resolveLocalRootDir();
|
|
500
|
-
const absoluteRoot = path.resolve(process.cwd(), rootDir);
|
|
501
|
-
const absolutePath = path.join(absoluteRoot, storageKey);
|
|
502
|
-
|
|
503
|
-
if (!fs.existsSync(absolutePath)) {
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
try {
|
|
507
|
-
await fsPromises.unlink(absolutePath);
|
|
508
|
-
} catch (error) {
|
|
509
|
-
throw new InternalServerErrorException({
|
|
510
|
-
message: 'Failed to delete file content',
|
|
511
|
-
details: {
|
|
512
|
-
storageKey,
|
|
513
|
-
reason: error instanceof Error ? error.message : 'unknown',
|
|
514
|
-
},
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private resolveLocalRootDir(): string {
|
|
520
|
-
const value = this.configService.get<string>('filesLocal.rootDir');
|
|
521
|
-
if (!value) {
|
|
323
|
+
private async openStoredContent(storageDriver: string, storageKey: string): Promise<Readable> {
|
|
324
|
+
if (storageDriver !== this.storageAdapter.driver) {
|
|
522
325
|
throw new ServiceUnavailableException(
|
|
523
|
-
|
|
326
|
+
`File was stored with driver "${storageDriver}", but current adapter is "${this.storageAdapter.driver}".`,
|
|
524
327
|
);
|
|
525
328
|
}
|
|
526
|
-
return
|
|
329
|
+
return this.storageAdapter.open(storageKey);
|
|
527
330
|
}
|
|
528
331
|
|
|
529
|
-
private
|
|
530
|
-
|
|
531
|
-
bucket: string;
|
|
532
|
-
region: string;
|
|
533
|
-
endpoint?: string;
|
|
534
|
-
accessKeyId: string;
|
|
535
|
-
secretAccessKey: string;
|
|
536
|
-
forcePathStyle: boolean;
|
|
537
|
-
maxAttempts: number;
|
|
538
|
-
} {
|
|
539
|
-
const providerPreset =
|
|
540
|
-
this.configService.get<'minio' | 'r2' | 'aws' | 'custom'>('filesS3.providerPreset') ?? 'minio';
|
|
541
|
-
const bucket = this.configService.get<string>('filesS3.bucket');
|
|
542
|
-
const region = this.configService.get<string>('filesS3.region');
|
|
543
|
-
const endpoint = this.configService.get<string>('filesS3.endpoint');
|
|
544
|
-
const accessKeyId = this.configService.get<string>('filesS3.accessKeyId');
|
|
545
|
-
const secretAccessKey = this.configService.get<string>('filesS3.secretAccessKey');
|
|
546
|
-
const forcePathStyle = this.configService.get<boolean>('filesS3.forcePathStyle');
|
|
547
|
-
const maxAttempts = this.configService.get<number>('filesS3.maxAttempts') ?? 3;
|
|
548
|
-
|
|
549
|
-
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
|
|
550
|
-
throw new ServiceUnavailableException(
|
|
551
|
-
'files-s3 adapter is not configured. Install/add files-s3 and ensure FILES_S3_* env keys are set.',
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
if (providerPreset !== 'aws' && !endpoint) {
|
|
332
|
+
private async deleteStoredContent(storageDriver: string, storageKey: string): Promise<void> {
|
|
333
|
+
if (storageDriver !== this.storageAdapter.driver) {
|
|
555
334
|
throw new ServiceUnavailableException(
|
|
556
|
-
`
|
|
335
|
+
`File was stored with driver "${storageDriver}", but current adapter is "${this.storageAdapter.driver}".`,
|
|
557
336
|
);
|
|
558
337
|
}
|
|
559
|
-
|
|
560
|
-
return {
|
|
561
|
-
providerPreset,
|
|
562
|
-
bucket,
|
|
563
|
-
region,
|
|
564
|
-
endpoint,
|
|
565
|
-
accessKeyId,
|
|
566
|
-
secretAccessKey,
|
|
567
|
-
forcePathStyle: forcePathStyle !== false,
|
|
568
|
-
maxAttempts,
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
private async getS3Client(): Promise<{
|
|
573
|
-
send: (command: unknown) => Promise<{
|
|
574
|
-
Body?: unknown;
|
|
575
|
-
}>;
|
|
576
|
-
}> {
|
|
577
|
-
if (this.s3Client) {
|
|
578
|
-
return this.s3Client;
|
|
579
|
-
}
|
|
580
|
-
const { S3Client } = await this.loadS3Module();
|
|
581
|
-
const config = this.resolveS3Config();
|
|
582
|
-
this.s3Client = new S3Client({
|
|
583
|
-
region: config.region,
|
|
584
|
-
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
585
|
-
forcePathStyle: config.forcePathStyle,
|
|
586
|
-
maxAttempts: config.maxAttempts,
|
|
587
|
-
credentials: {
|
|
588
|
-
accessKeyId: config.accessKeyId,
|
|
589
|
-
secretAccessKey: config.secretAccessKey,
|
|
590
|
-
},
|
|
591
|
-
});
|
|
592
|
-
return this.s3Client;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
private toNodeReadable(body: unknown): Readable {
|
|
596
|
-
if (body instanceof Readable) {
|
|
597
|
-
return body;
|
|
598
|
-
}
|
|
599
|
-
if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
|
600
|
-
return Readable.from(body);
|
|
601
|
-
}
|
|
602
|
-
if (
|
|
603
|
-
typeof body === 'object' &&
|
|
604
|
-
body !== null &&
|
|
605
|
-
typeof (body as { transformToWebStream?: unknown }).transformToWebStream === 'function'
|
|
606
|
-
) {
|
|
607
|
-
return Readable.fromWeb(
|
|
608
|
-
(body as { transformToWebStream: () => unknown }).transformToWebStream() as never,
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
throw new InternalServerErrorException('Unsupported S3 response body type');
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
private async loadS3Module(): Promise<S3ModuleLike> {
|
|
615
|
-
const importModule = new Function('specifier', 'return import(specifier)') as (
|
|
616
|
-
specifier: string,
|
|
617
|
-
) => Promise<S3ModuleLike>;
|
|
618
|
-
return importModule('@aws-sdk/client-s3');
|
|
338
|
+
await this.storageAdapter.delete(storageKey);
|
|
619
339
|
}
|
|
620
340
|
|
|
621
341
|
private generatePublicId(): string {
|
|
@@ -633,22 +353,13 @@ export class FilesService {
|
|
|
633
353
|
private async cleanupReferencedBlobs(blobIds: string[]): Promise<void> {
|
|
634
354
|
const uniqueIds = [...new Set(blobIds.filter(Boolean))];
|
|
635
355
|
for (const blobId of uniqueIds) {
|
|
636
|
-
const blob = await this.
|
|
637
|
-
where: { id: blobId },
|
|
638
|
-
});
|
|
356
|
+
const blob = await this.persistence.findBlobById(blobId);
|
|
639
357
|
if (!blob) {
|
|
640
358
|
continue;
|
|
641
359
|
}
|
|
642
360
|
|
|
643
|
-
const deleted = await this.
|
|
644
|
-
|
|
645
|
-
id: blob.id,
|
|
646
|
-
variants: {
|
|
647
|
-
none: {},
|
|
648
|
-
},
|
|
649
|
-
},
|
|
650
|
-
});
|
|
651
|
-
if (deleted.count === 0) {
|
|
361
|
+
const deleted = await this.persistence.deleteBlobIfUnreferenced(blob.id);
|
|
362
|
+
if (!deleted) {
|
|
652
363
|
continue;
|
|
653
364
|
}
|
|
654
365
|
await this.deleteStoredContent(blob.storageDriver, blob.storageKey).catch(() => undefined);
|
|
@@ -660,27 +371,12 @@ export class FilesService {
|
|
|
660
371
|
size: number,
|
|
661
372
|
mimeType: string,
|
|
662
373
|
storageDriver: string,
|
|
663
|
-
): Promise<
|
|
664
|
-
const existing = await this.
|
|
665
|
-
where: {
|
|
666
|
-
hash,
|
|
667
|
-
size,
|
|
668
|
-
mimeType,
|
|
669
|
-
storageDriver,
|
|
670
|
-
},
|
|
671
|
-
});
|
|
374
|
+
): Promise<FilesBlobRef | null> {
|
|
375
|
+
const existing = await this.persistence.findBlobRef(hash, size, mimeType, storageDriver);
|
|
672
376
|
if (!existing) {
|
|
673
377
|
return null;
|
|
674
378
|
}
|
|
675
|
-
return
|
|
676
|
-
id: existing.id,
|
|
677
|
-
hash: existing.hash,
|
|
678
|
-
size: existing.size,
|
|
679
|
-
mimeType: existing.mimeType,
|
|
680
|
-
storageDriver: existing.storageDriver,
|
|
681
|
-
storageKey: existing.storageKey,
|
|
682
|
-
created: false,
|
|
683
|
-
};
|
|
379
|
+
return existing;
|
|
684
380
|
}
|
|
685
381
|
|
|
686
382
|
private isUniqueConstraintError(error: unknown): boolean {
|
|
@@ -690,6 +386,13 @@ export class FilesService {
|
|
|
690
386
|
return (error as PrismaLikeError).code === 'P2002';
|
|
691
387
|
}
|
|
692
388
|
|
|
389
|
+
protected normalizeFileName(originalName: string, extension: string, suffix?: string): string {
|
|
390
|
+
const parsed = path.parse(originalName);
|
|
391
|
+
const safeExtension = extension.startsWith('.') ? extension : `.${extension}`;
|
|
392
|
+
const base = suffix ? `${parsed.name}-${suffix}` : parsed.name;
|
|
393
|
+
return `${base}${safeExtension}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
693
396
|
private buildVariantFileName(originalName: string, variant: FileVariantKey, mimeType: string): string {
|
|
694
397
|
if (variant === 'original') {
|
|
695
398
|
return originalName;
|
|
@@ -709,24 +412,7 @@ export class FilesService {
|
|
|
709
412
|
return null;
|
|
710
413
|
}
|
|
711
414
|
|
|
712
|
-
private toDto(record: {
|
|
713
|
-
id: string;
|
|
714
|
-
publicId: string;
|
|
715
|
-
storageKey: string;
|
|
716
|
-
originalName: string;
|
|
717
|
-
mimeType: string;
|
|
718
|
-
size: number;
|
|
719
|
-
storageDriver: string;
|
|
720
|
-
ownerType: string;
|
|
721
|
-
ownerId: string | null;
|
|
722
|
-
visibility: string;
|
|
723
|
-
createdById: string | null;
|
|
724
|
-
createdAt: Date;
|
|
725
|
-
updatedAt: Date;
|
|
726
|
-
variants?: Array<{
|
|
727
|
-
variantKey: string;
|
|
728
|
-
}>;
|
|
729
|
-
}): FileRecordDto {
|
|
415
|
+
private toDto(record: FilesRecordAggregate): FileRecordDto {
|
|
730
416
|
const availableVariants = new Set<FileVariantKey>(['original']);
|
|
731
417
|
for (const variant of record.variants ?? []) {
|
|
732
418
|
if (variant.variantKey === 'original' || variant.variantKey === 'preview') {
|
|
@@ -759,4 +445,4 @@ export class FilesService {
|
|
|
759
445
|
: `/${this.filesConfigService.publicBasePath}`;
|
|
760
446
|
return `${basePath}/${publicId}/download`;
|
|
761
447
|
}
|
|
762
|
-
}
|
|
448
|
+
}
|