create-forgeon 0.3.20 → 0.3.21

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.
@@ -1,514 +1,514 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
- import {
5
- ensureBuildStepBefore,
6
- ensureBuildSteps,
7
- ensureClassMember,
8
- ensureDependency,
9
- ensureImportLine,
10
- ensureLineAfter,
11
- ensureLineBefore,
12
- ensureLoadItem,
13
- ensureNestCommonImport,
14
- ensureValidatorSchema,
15
- upsertEnvLines,
16
- } from './shared/patch-utils.mjs';
17
- import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
18
-
19
- function copyFromPreset(packageRoot, targetRoot, relativePath) {
20
- const source = path.join(packageRoot, 'templates', 'module-presets', 'files-image', relativePath);
21
- if (!fs.existsSync(source)) {
22
- throw new Error(`Missing files-image preset template: ${source}`);
23
- }
24
- const destination = path.join(targetRoot, relativePath);
25
- copyRecursive(source, destination);
26
- }
27
-
28
- function patchApiPackage(targetRoot) {
29
- const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
30
- if (!fs.existsSync(packagePath)) {
31
- return;
32
- }
33
-
34
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
35
- ensureDependency(packageJson, '@forgeon/files-image', 'workspace:*');
36
- ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files-image build']);
37
- ensureBuildStepBefore(
38
- packageJson,
39
- 'predev',
40
- 'pnpm --filter @forgeon/files-image build',
41
- 'pnpm --filter @forgeon/files build',
42
- );
43
- writeJson(packagePath, packageJson);
44
- }
45
-
46
- function patchFilesPackage(targetRoot) {
47
- const packagePath = path.join(targetRoot, 'packages', 'files', 'package.json');
48
- if (!fs.existsSync(packagePath)) {
49
- return;
50
- }
51
-
52
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
53
- ensureDependency(packageJson, '@forgeon/files-image', 'workspace:*');
54
- writeJson(packagePath, packageJson);
55
- }
56
-
57
- function patchRootPackage(targetRoot) {
58
- const packagePath = path.join(targetRoot, 'package.json');
59
- if (!fs.existsSync(packagePath)) {
60
- return;
61
- }
62
-
63
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
64
- if (!packageJson.pnpm || typeof packageJson.pnpm !== 'object' || Array.isArray(packageJson.pnpm)) {
65
- packageJson.pnpm = {};
66
- }
67
-
68
- const onlyBuiltDependencies = Array.isArray(packageJson.pnpm.onlyBuiltDependencies)
69
- ? packageJson.pnpm.onlyBuiltDependencies
70
- : [];
71
-
72
- if (!onlyBuiltDependencies.includes('sharp')) {
73
- onlyBuiltDependencies.push('sharp');
74
- }
75
-
76
- packageJson.pnpm.onlyBuiltDependencies = onlyBuiltDependencies;
77
- writeJson(packagePath, packageJson);
78
- }
79
-
80
-
81
- function patchAppModule(targetRoot) {
82
- const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
83
- if (!fs.existsSync(filePath)) {
84
- return;
85
- }
86
-
87
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
88
- content = ensureImportLine(
89
- content,
90
- "import { filesImageConfig, filesImageEnvSchema, ForgeonFilesImageModule } from '@forgeon/files-image';",
91
- );
92
- content = ensureLoadItem(content, 'filesImageConfig');
93
- content = ensureValidatorSchema(content, 'filesImageEnvSchema');
94
-
95
- if (!content.includes(' ForgeonFilesImageModule,')) {
96
- if (content.includes(' ForgeonI18nModule.register({')) {
97
- content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonFilesImageModule,');
98
- } else if (content.includes(' ForgeonAccountsModule.register({')) {
99
- content = ensureLineBefore(content, ' ForgeonAccountsModule.register({', ' ForgeonFilesImageModule,');
100
- } else if (content.includes(' ForgeonAccountsModule.register(),')) {
101
- content = ensureLineBefore(content, ' ForgeonAccountsModule.register(),', ' ForgeonFilesImageModule,');
102
- } else if (content.includes(' ForgeonFilesModule,')) {
103
- content = ensureLineAfter(content, ' ForgeonFilesModule,', ' ForgeonFilesImageModule,');
104
- } else if (content.includes(' DbPrismaModule,')) {
105
- content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonFilesImageModule,');
106
- } else if (content.includes(' ForgeonLoggerModule,')) {
107
- content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonFilesImageModule,');
108
- } else if (content.includes(' ForgeonSwaggerModule,')) {
109
- content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonFilesImageModule,');
110
- } else {
111
- content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonFilesImageModule,');
112
- }
113
- }
114
-
115
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
116
- }
117
-
118
- function patchFilesModule(targetRoot) {
119
- const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts');
120
- if (!fs.existsSync(filePath)) {
121
- return;
122
- }
123
-
124
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
125
- content = ensureImportLine(content, "import { ForgeonFilesImageModule } from '@forgeon/files-image';");
126
- content = content.replace(
127
- 'imports: [FilesConfigModule],',
128
- 'imports: [FilesConfigModule, ForgeonFilesImageModule],',
129
- );
130
-
131
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
132
- }
133
-
134
- function patchFilesController(targetRoot) {
135
- const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.controller.ts');
136
- if (!fs.existsSync(filePath)) {
137
- return;
138
- }
139
-
140
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
141
- content = ensureNestCommonImport(content, 'Req');
142
-
143
- if (!content.includes('auditContext: {')) {
144
- content = content.replace(
145
- ` async uploadFile(
146
- @UploadedFile() file: UploadedFileShape | undefined,
147
- @Body() body: CreateFileDto,
148
- ) {`,
149
- ` async uploadFile(
150
- @UploadedFile() file: UploadedFileShape | undefined,
151
- @Body() body: CreateFileDto,
152
- @Req() req: any,
153
- ) {`,
154
- );
155
-
156
- content = content.replace(
157
- ` createdById: body.createdById,
158
- });`,
159
- ` createdById: body.createdById,
160
- auditContext: {
161
- requestId:
162
- typeof req?.headers?.['x-request-id'] === 'string' ? req.headers['x-request-id'] : null,
163
- ip: typeof req?.ip === 'string' ? req.ip : null,
164
- userId: typeof body.createdById === 'string' && body.createdById.length > 0 ? body.createdById : null,
165
- },
166
- });`,
167
- );
168
- }
169
-
170
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
171
- }
172
-
173
- function patchFilesService(targetRoot) {
174
- const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.service.ts');
175
- if (!fs.existsSync(filePath)) {
176
- return;
177
- }
178
-
179
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
180
- content = ensureImportLine(content, "import { FilesImageService } from '@forgeon/files-image';");
181
-
182
- if (!content.includes('private readonly filesImageService: FilesImageService')) {
183
- const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
184
- if (constructorMatch) {
185
- const original = constructorMatch[0];
186
- const inner = constructorMatch[1].trimEnd();
187
- const normalizedInner = inner.replace(/,\s*$/, '');
188
- const separator = normalizedInner.length > 0 ? ',' : '';
189
- const next = `constructor(${normalizedInner}${separator}
190
- private readonly filesImageService: FilesImageService,
191
- ) {`;
192
- content = content.replace(original, next);
193
- }
194
- }
195
-
196
- if (!content.includes('filesImageService.sanitizeForStorage')) {
197
- content = content.replace(
198
- ` protected async prepareOriginalForStorage(input: StoredFileInput): Promise<PreparedStoredFile> {
199
- return {
200
- buffer: input.buffer,
201
- mimeType: input.mimeType,
202
- size: input.size,
203
- fileName: input.originalName,
204
- };
205
- }`,
206
- ` protected async prepareOriginalForStorage(input: StoredFileInput): Promise<PreparedStoredFile> {
207
- const sanitized = await this.filesImageService.sanitizeForStorage({
208
- buffer: input.buffer,
209
- declaredMimeType: input.mimeType,
210
- originalName: input.originalName,
211
- auditContext: input.auditContext,
212
- });
213
- return {
214
- buffer: sanitized.buffer,
215
- mimeType: sanitized.mimeType,
216
- size: sanitized.buffer.byteLength,
217
- fileName: this.normalizeFileName(input.originalName, sanitized.extension),
218
- };
219
- }`,
220
- );
221
-
222
- content = content.replace(
223
- ` protected async buildPreviewVariant(
224
- _preparedOriginal: PreparedStoredFile,
225
- _input: StoredFileInput,
226
- ): Promise<PreparedStoredFile | null> {
227
- return null;
228
- }`,
229
- ` protected async buildPreviewVariant(
230
- preparedOriginal: PreparedStoredFile,
231
- input: StoredFileInput,
232
- ): Promise<PreparedStoredFile | null> {
233
- const preview = await this.filesImageService.buildPreviewVariant({
234
- buffer: preparedOriginal.buffer,
235
- declaredMimeType: preparedOriginal.mimeType,
236
- originalName: input.originalName,
237
- auditContext: input.auditContext,
238
- });
239
- if (!preview) {
240
- return null;
241
- }
242
-
243
- return {
244
- buffer: preview.buffer,
245
- mimeType: preview.mimeType,
246
- size: preview.buffer.byteLength,
247
- fileName: this.normalizeFileName(input.originalName, preview.extension, 'preview'),
248
- };
249
- }`,
250
- );
251
-
252
- content = content.replace(
253
- ` protected async isPreviewGenerationEnabled(): Promise<boolean> {
254
- return false;
255
- }`,
256
- ` protected async isPreviewGenerationEnabled(): Promise<boolean> {
257
- return this.filesImageService.isPreviewEnabled();
258
- }`,
259
- );
260
-
261
- if (!content.includes('protected normalizeFileName(originalName: string, extension: string, suffix?: string): string')) {
262
- content = content.replace(
263
- ` private extensionFromMime(mimeType: string): string | null {`,
264
- ` protected normalizeFileName(originalName: string, extension: string, suffix?: string): string {
265
- const parsed = path.parse(originalName);
266
- const safeExtension = extension.startsWith('.') ? extension : \`.\${extension}\`;
267
- const base = suffix ? \`\${parsed.name}-\${suffix}\` : parsed.name;
268
- return \`\${base}\${safeExtension}\`;
269
- }
270
-
271
- private extensionFromMime(mimeType: string): string | null {`,
272
- );
273
- }
274
- }
275
-
276
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
277
- }
278
-
279
- function patchHealthController(targetRoot, probeTargets) {
280
- if (!probeTargets.allowApi) {
281
- return;
282
- }
283
-
284
- const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
285
- if (!fs.existsSync(filePath)) {
286
- return;
287
- }
288
-
289
- let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
290
- content = ensureImportLine(content, "import { FilesImageService } from '@forgeon/files-image';");
291
- content = ensureNestCommonImport(content, 'Get');
292
-
293
- if (!content.includes('private readonly filesImageService: FilesImageService')) {
294
- const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
295
- if (constructorMatch) {
296
- const original = constructorMatch[0];
297
- const inner = constructorMatch[1].trimEnd();
298
- const normalizedInner = inner.replace(/,\s*$/, '');
299
- const separator = normalizedInner.length > 0 ? ',' : '';
300
- const next = `constructor(${normalizedInner}${separator}
301
- private readonly filesImageService: FilesImageService,
302
- ) {`;
303
- content = content.replace(original, next);
304
- }
305
- }
306
-
307
- if (!content.includes("@Get('files-image')")) {
308
- const method = `
309
- @Get('files-image')
310
- async getFilesImageProbe() {
311
- return this.filesImageService.getProbeStatus();
312
- }
313
- `;
314
- content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
315
- }
316
-
317
- fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
318
- }
319
-
320
- function registerWebProbe(targetRoot, probeTargets) {
321
- ensureWebProbeDefinition({
322
- targetRoot,
323
- probeTargets,
324
- definition: {
325
- id: 'files-image',
326
- title: 'Files Image',
327
- buttonLabel: 'Check files image sanitize',
328
- resultTitle: 'Files image probe response',
329
- path: '/health/files-image',
330
- },
331
- });
332
- }
333
-
334
- function patchApiDockerfile(targetRoot) {
335
- const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
336
- if (!fs.existsSync(dockerfilePath)) {
337
- return;
338
- }
339
-
340
- let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
341
- const packageAnchors = [
342
- 'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
343
- 'COPY packages/files-access/package.json packages/files-access/package.json',
344
- 'COPY packages/files/package.json packages/files/package.json',
345
- 'COPY packages/files-local/package.json packages/files-local/package.json',
346
- 'COPY packages/files-s3/package.json packages/files-s3/package.json',
347
- 'COPY packages/accounts-api/package.json packages/accounts-api/package.json',
348
- 'COPY packages/rbac/package.json packages/rbac/package.json',
349
- 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
350
- 'COPY packages/logger/package.json packages/logger/package.json',
351
- 'COPY packages/swagger/package.json packages/swagger/package.json',
352
- 'COPY packages/i18n/package.json packages/i18n/package.json',
353
- 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
354
- 'COPY packages/core/package.json packages/core/package.json',
355
- ];
356
- const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
357
- content = ensureLineAfter(
358
- content,
359
- packageAnchor,
360
- 'COPY packages/files-image/package.json packages/files-image/package.json',
361
- );
362
-
363
- const sourceAnchors = [
364
- 'COPY packages/files-quotas packages/files-quotas',
365
- 'COPY packages/files-access packages/files-access',
366
- 'COPY packages/files packages/files',
367
- 'COPY packages/files-local packages/files-local',
368
- 'COPY packages/files-s3 packages/files-s3',
369
- 'COPY packages/accounts-api packages/accounts-api',
370
- 'COPY packages/rbac packages/rbac',
371
- 'COPY packages/rate-limit packages/rate-limit',
372
- 'COPY packages/logger packages/logger',
373
- 'COPY packages/swagger packages/swagger',
374
- 'COPY packages/i18n packages/i18n',
375
- 'COPY packages/db-prisma packages/db-prisma',
376
- 'COPY packages/core packages/core',
377
- ];
378
- const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
379
- content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files-image packages/files-image');
380
-
381
- content = content.replace(/^RUN pnpm --filter @forgeon\/files-image build\r?\n?/gm, '');
382
- const buildAnchor = content.includes('RUN pnpm --filter @forgeon/files build')
383
- ? 'RUN pnpm --filter @forgeon/files build'
384
- : content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
385
- ? 'RUN pnpm --filter @forgeon/api prisma:generate'
386
- : 'RUN pnpm --filter @forgeon/api build';
387
- content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files-image build');
388
-
389
- fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
390
- }
391
-
392
- function patchCompose(targetRoot) {
393
- const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
394
- if (!fs.existsSync(composePath)) {
395
- return;
396
- }
397
-
398
- let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
399
- if (!content.includes('FILES_IMAGE_ENABLED: ${FILES_IMAGE_ENABLED}')) {
400
- const anchors = [
401
- /^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
402
- /^(\s+FILES_QUOTAS_ENABLED:.*)$/m,
403
- /^(\s+FILES_ALLOWED_MIME_PREFIXES:.*)$/m,
404
- /^(\s+FILES_MAX_FILE_SIZE_BYTES:.*)$/m,
405
- /^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
406
- /^(\s+FILES_STORAGE_DRIVER:.*)$/m,
407
- /^(\s+FILES_ENABLED:.*)$/m,
408
- /^(\s+API_PREFIX:.*)$/m,
409
- ];
410
- const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
411
- content = content.replace(
412
- anchorPattern,
413
- `$1
414
- FILES_IMAGE_ENABLED: \${FILES_IMAGE_ENABLED}
415
- FILES_IMAGE_STRIP_METADATA: \${FILES_IMAGE_STRIP_METADATA}
416
- FILES_IMAGE_MAX_WIDTH: \${FILES_IMAGE_MAX_WIDTH}
417
- FILES_IMAGE_MAX_HEIGHT: \${FILES_IMAGE_MAX_HEIGHT}
418
- FILES_IMAGE_MAX_PIXELS: \${FILES_IMAGE_MAX_PIXELS}
419
- FILES_IMAGE_MAX_FRAMES: \${FILES_IMAGE_MAX_FRAMES}
420
- FILES_IMAGE_PROCESS_TIMEOUT_MS: \${FILES_IMAGE_PROCESS_TIMEOUT_MS}
421
- FILES_IMAGE_ALLOWED_MIME_TYPES: \${FILES_IMAGE_ALLOWED_MIME_TYPES}`,
422
- );
423
- }
424
-
425
- fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
426
- }
427
-
428
- function patchReadme(targetRoot) {
429
- const readmePath = path.join(targetRoot, 'README.md');
430
- if (!fs.existsSync(readmePath)) {
431
- return;
432
- }
433
-
434
- const marker = '## Files Image Module';
435
- let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
436
- if (content.includes(marker)) {
437
- return;
438
- }
439
-
440
- const section = `## Files Image Module
441
-
442
- The files-image module adds image content validation by magic bytes and sanitize/re-encode before storage.
443
-
444
- What it adds:
445
- - \`@forgeon/files-image\` package (\`sharp + file-type\`)
446
- - image pipeline in files runtime:
447
- - detect actual type by magic bytes
448
- - reject declared/detected mismatches
449
- - decode -> sanitize -> re-encode
450
- - generate optional \`preview\` file variant for image uploads
451
- - probe endpoint: \`GET /api/health/files-image\`
452
-
453
- Default security behavior:
454
- - metadata is stripped before storage (\`FILES_IMAGE_STRIP_METADATA=true\`)
455
-
456
- Key env:
457
- - \`FILES_IMAGE_ENABLED=true\`
458
- - \`FILES_IMAGE_STRIP_METADATA=true\`
459
- - \`FILES_IMAGE_MAX_WIDTH=4096\`
460
- - \`FILES_IMAGE_MAX_HEIGHT=4096\`
461
- - \`FILES_IMAGE_MAX_PIXELS=16777216\`
462
- - \`FILES_IMAGE_MAX_FRAMES=1\`
463
- - \`FILES_IMAGE_PROCESS_TIMEOUT_MS=5000\`
464
- - \`FILES_IMAGE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp\``;
465
-
466
- if (content.includes('## Prisma In Docker Start')) {
467
- content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
468
- } else {
469
- content = `${content.trimEnd()}\n\n${section}\n`;
470
- }
471
-
472
- fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
473
- }
474
-
475
- export function applyFilesImageModule({ packageRoot, targetRoot }) {
476
- copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files-image'));
477
- const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'files-image' });
478
-
479
- patchApiPackage(targetRoot);
480
- patchFilesPackage(targetRoot);
481
- patchRootPackage(targetRoot);
482
- patchAppModule(targetRoot);
483
- patchFilesModule(targetRoot);
484
- patchFilesController(targetRoot);
485
- patchFilesService(targetRoot);
486
- patchHealthController(targetRoot, probeTargets);
487
- registerWebProbe(targetRoot, probeTargets);
488
- patchApiDockerfile(targetRoot);
489
- patchCompose(targetRoot);
490
- patchReadme(targetRoot);
491
-
492
- upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
493
- 'FILES_IMAGE_ENABLED=true',
494
- 'FILES_IMAGE_STRIP_METADATA=true',
495
- 'FILES_IMAGE_MAX_WIDTH=4096',
496
- 'FILES_IMAGE_MAX_HEIGHT=4096',
497
- 'FILES_IMAGE_MAX_PIXELS=16777216',
498
- 'FILES_IMAGE_MAX_FRAMES=1',
499
- 'FILES_IMAGE_PROCESS_TIMEOUT_MS=5000',
500
- 'FILES_IMAGE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp',
501
- ]);
502
- upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
503
- 'FILES_IMAGE_ENABLED=true',
504
- 'FILES_IMAGE_STRIP_METADATA=true',
505
- 'FILES_IMAGE_MAX_WIDTH=4096',
506
- 'FILES_IMAGE_MAX_HEIGHT=4096',
507
- 'FILES_IMAGE_MAX_PIXELS=16777216',
508
- 'FILES_IMAGE_MAX_FRAMES=1',
509
- 'FILES_IMAGE_PROCESS_TIMEOUT_MS=5000',
510
- 'FILES_IMAGE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp',
511
- ]);
512
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+ import {
5
+ ensureBuildStepBefore,
6
+ ensureBuildSteps,
7
+ ensureClassMember,
8
+ ensureDependency,
9
+ ensureImportLine,
10
+ ensureLineAfter,
11
+ ensureLineBefore,
12
+ ensureLoadItem,
13
+ ensureNestCommonImport,
14
+ ensureValidatorSchema,
15
+ upsertEnvLines,
16
+ } from './shared/patch-utils.mjs';
17
+ import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
18
+
19
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
20
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'files-image', relativePath);
21
+ if (!fs.existsSync(source)) {
22
+ throw new Error(`Missing files-image preset template: ${source}`);
23
+ }
24
+ const destination = path.join(targetRoot, relativePath);
25
+ copyRecursive(source, destination);
26
+ }
27
+
28
+ function patchApiPackage(targetRoot) {
29
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
30
+ if (!fs.existsSync(packagePath)) {
31
+ return;
32
+ }
33
+
34
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
35
+ ensureDependency(packageJson, '@forgeon/files-image', 'workspace:*');
36
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files-image build']);
37
+ ensureBuildStepBefore(
38
+ packageJson,
39
+ 'predev',
40
+ 'pnpm --filter @forgeon/files-image build',
41
+ 'pnpm --filter @forgeon/files build',
42
+ );
43
+ writeJson(packagePath, packageJson);
44
+ }
45
+
46
+ function patchFilesPackage(targetRoot) {
47
+ const packagePath = path.join(targetRoot, 'packages', 'files', 'package.json');
48
+ if (!fs.existsSync(packagePath)) {
49
+ return;
50
+ }
51
+
52
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
53
+ ensureDependency(packageJson, '@forgeon/files-image', 'workspace:*');
54
+ writeJson(packagePath, packageJson);
55
+ }
56
+
57
+ function patchRootPackage(targetRoot) {
58
+ const packagePath = path.join(targetRoot, 'package.json');
59
+ if (!fs.existsSync(packagePath)) {
60
+ return;
61
+ }
62
+
63
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
64
+ if (!packageJson.pnpm || typeof packageJson.pnpm !== 'object' || Array.isArray(packageJson.pnpm)) {
65
+ packageJson.pnpm = {};
66
+ }
67
+
68
+ const onlyBuiltDependencies = Array.isArray(packageJson.pnpm.onlyBuiltDependencies)
69
+ ? packageJson.pnpm.onlyBuiltDependencies
70
+ : [];
71
+
72
+ if (!onlyBuiltDependencies.includes('sharp')) {
73
+ onlyBuiltDependencies.push('sharp');
74
+ }
75
+
76
+ packageJson.pnpm.onlyBuiltDependencies = onlyBuiltDependencies;
77
+ writeJson(packagePath, packageJson);
78
+ }
79
+
80
+
81
+ function patchAppModule(targetRoot) {
82
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
83
+ if (!fs.existsSync(filePath)) {
84
+ return;
85
+ }
86
+
87
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
88
+ content = ensureImportLine(
89
+ content,
90
+ "import { filesImageConfig, filesImageEnvSchema, ForgeonFilesImageModule } from '@forgeon/files-image';",
91
+ );
92
+ content = ensureLoadItem(content, 'filesImageConfig');
93
+ content = ensureValidatorSchema(content, 'filesImageEnvSchema');
94
+
95
+ if (!content.includes(' ForgeonFilesImageModule,')) {
96
+ if (content.includes(' ForgeonI18nModule.register({')) {
97
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonFilesImageModule,');
98
+ } else if (content.includes(' ForgeonAccountsModule.register({')) {
99
+ content = ensureLineBefore(content, ' ForgeonAccountsModule.register({', ' ForgeonFilesImageModule,');
100
+ } else if (content.includes(' ForgeonAccountsModule.register(),')) {
101
+ content = ensureLineBefore(content, ' ForgeonAccountsModule.register(),', ' ForgeonFilesImageModule,');
102
+ } else if (content.includes(' ForgeonFilesModule,')) {
103
+ content = ensureLineAfter(content, ' ForgeonFilesModule,', ' ForgeonFilesImageModule,');
104
+ } else if (content.includes(' DbPrismaModule,')) {
105
+ content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonFilesImageModule,');
106
+ } else if (content.includes(' ForgeonLoggerModule,')) {
107
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonFilesImageModule,');
108
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
109
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonFilesImageModule,');
110
+ } else {
111
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonFilesImageModule,');
112
+ }
113
+ }
114
+
115
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
116
+ }
117
+
118
+ function patchFilesModule(targetRoot) {
119
+ const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'forgeon-files.module.ts');
120
+ if (!fs.existsSync(filePath)) {
121
+ return;
122
+ }
123
+
124
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
125
+ content = ensureImportLine(content, "import { ForgeonFilesImageModule } from '@forgeon/files-image';");
126
+ content = content.replace(
127
+ 'imports: [FilesConfigModule],',
128
+ 'imports: [FilesConfigModule, ForgeonFilesImageModule],',
129
+ );
130
+
131
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
132
+ }
133
+
134
+ function patchFilesController(targetRoot) {
135
+ const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.controller.ts');
136
+ if (!fs.existsSync(filePath)) {
137
+ return;
138
+ }
139
+
140
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
141
+ content = ensureNestCommonImport(content, 'Req');
142
+
143
+ if (!content.includes('auditContext: {')) {
144
+ content = content.replace(
145
+ ` async uploadFile(
146
+ @UploadedFile() file: UploadedFileShape | undefined,
147
+ @Body() body: CreateFileDto,
148
+ ) {`,
149
+ ` async uploadFile(
150
+ @UploadedFile() file: UploadedFileShape | undefined,
151
+ @Body() body: CreateFileDto,
152
+ @Req() req: any,
153
+ ) {`,
154
+ );
155
+
156
+ content = content.replace(
157
+ ` createdById: body.createdById,
158
+ });`,
159
+ ` createdById: body.createdById,
160
+ auditContext: {
161
+ requestId:
162
+ typeof req?.headers?.['x-request-id'] === 'string' ? req.headers['x-request-id'] : null,
163
+ ip: typeof req?.ip === 'string' ? req.ip : null,
164
+ userId: typeof body.createdById === 'string' && body.createdById.length > 0 ? body.createdById : null,
165
+ },
166
+ });`,
167
+ );
168
+ }
169
+
170
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
171
+ }
172
+
173
+ function patchFilesService(targetRoot) {
174
+ const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.service.ts');
175
+ if (!fs.existsSync(filePath)) {
176
+ return;
177
+ }
178
+
179
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
180
+ content = ensureImportLine(content, "import { FilesImageService } from '@forgeon/files-image';");
181
+
182
+ if (!content.includes('private readonly filesImageService: FilesImageService')) {
183
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
184
+ if (constructorMatch) {
185
+ const original = constructorMatch[0];
186
+ const inner = constructorMatch[1].trimEnd();
187
+ const normalizedInner = inner.replace(/,\s*$/, '');
188
+ const separator = normalizedInner.length > 0 ? ',' : '';
189
+ const next = `constructor(${normalizedInner}${separator}
190
+ private readonly filesImageService: FilesImageService,
191
+ ) {`;
192
+ content = content.replace(original, next);
193
+ }
194
+ }
195
+
196
+ if (!content.includes('filesImageService.sanitizeForStorage')) {
197
+ content = content.replace(
198
+ ` protected async prepareOriginalForStorage(input: StoredFileInput): Promise<PreparedStoredFile> {
199
+ return {
200
+ buffer: input.buffer,
201
+ mimeType: input.mimeType,
202
+ size: input.size,
203
+ fileName: input.originalName,
204
+ };
205
+ }`,
206
+ ` protected async prepareOriginalForStorage(input: StoredFileInput): Promise<PreparedStoredFile> {
207
+ const sanitized = await this.filesImageService.sanitizeForStorage({
208
+ buffer: input.buffer,
209
+ declaredMimeType: input.mimeType,
210
+ originalName: input.originalName,
211
+ auditContext: input.auditContext,
212
+ });
213
+ return {
214
+ buffer: sanitized.buffer,
215
+ mimeType: sanitized.mimeType,
216
+ size: sanitized.buffer.byteLength,
217
+ fileName: this.normalizeFileName(input.originalName, sanitized.extension),
218
+ };
219
+ }`,
220
+ );
221
+
222
+ content = content.replace(
223
+ ` protected async buildPreviewVariant(
224
+ _preparedOriginal: PreparedStoredFile,
225
+ _input: StoredFileInput,
226
+ ): Promise<PreparedStoredFile | null> {
227
+ return null;
228
+ }`,
229
+ ` protected async buildPreviewVariant(
230
+ preparedOriginal: PreparedStoredFile,
231
+ input: StoredFileInput,
232
+ ): Promise<PreparedStoredFile | null> {
233
+ const preview = await this.filesImageService.buildPreviewVariant({
234
+ buffer: preparedOriginal.buffer,
235
+ declaredMimeType: preparedOriginal.mimeType,
236
+ originalName: input.originalName,
237
+ auditContext: input.auditContext,
238
+ });
239
+ if (!preview) {
240
+ return null;
241
+ }
242
+
243
+ return {
244
+ buffer: preview.buffer,
245
+ mimeType: preview.mimeType,
246
+ size: preview.buffer.byteLength,
247
+ fileName: this.normalizeFileName(input.originalName, preview.extension, 'preview'),
248
+ };
249
+ }`,
250
+ );
251
+
252
+ content = content.replace(
253
+ ` protected async isPreviewGenerationEnabled(): Promise<boolean> {
254
+ return false;
255
+ }`,
256
+ ` protected async isPreviewGenerationEnabled(): Promise<boolean> {
257
+ return this.filesImageService.isPreviewEnabled();
258
+ }`,
259
+ );
260
+
261
+ if (!content.includes('protected normalizeFileName(originalName: string, extension: string, suffix?: string): string')) {
262
+ content = content.replace(
263
+ ` private extensionFromMime(mimeType: string): string | null {`,
264
+ ` protected normalizeFileName(originalName: string, extension: string, suffix?: string): string {
265
+ const parsed = path.parse(originalName);
266
+ const safeExtension = extension.startsWith('.') ? extension : \`.\${extension}\`;
267
+ const base = suffix ? \`\${parsed.name}-\${suffix}\` : parsed.name;
268
+ return \`\${base}\${safeExtension}\`;
269
+ }
270
+
271
+ private extensionFromMime(mimeType: string): string | null {`,
272
+ );
273
+ }
274
+ }
275
+
276
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
277
+ }
278
+
279
+ function patchHealthController(targetRoot, probeTargets) {
280
+ if (!probeTargets.allowApi) {
281
+ return;
282
+ }
283
+
284
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
285
+ if (!fs.existsSync(filePath)) {
286
+ return;
287
+ }
288
+
289
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
290
+ content = ensureImportLine(content, "import { FilesImageService } from '@forgeon/files-image';");
291
+ content = ensureNestCommonImport(content, 'Get');
292
+
293
+ if (!content.includes('private readonly filesImageService: FilesImageService')) {
294
+ const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
295
+ if (constructorMatch) {
296
+ const original = constructorMatch[0];
297
+ const inner = constructorMatch[1].trimEnd();
298
+ const normalizedInner = inner.replace(/,\s*$/, '');
299
+ const separator = normalizedInner.length > 0 ? ',' : '';
300
+ const next = `constructor(${normalizedInner}${separator}
301
+ private readonly filesImageService: FilesImageService,
302
+ ) {`;
303
+ content = content.replace(original, next);
304
+ }
305
+ }
306
+
307
+ if (!content.includes("@Get('files-image')")) {
308
+ const method = `
309
+ @Get('files-image')
310
+ async getFilesImageProbe() {
311
+ return this.filesImageService.getProbeStatus();
312
+ }
313
+ `;
314
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
315
+ }
316
+
317
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
318
+ }
319
+
320
+ function registerWebProbe(targetRoot, probeTargets) {
321
+ ensureWebProbeDefinition({
322
+ targetRoot,
323
+ probeTargets,
324
+ definition: {
325
+ id: 'files-image',
326
+ title: 'Files Image',
327
+ buttonLabel: 'Check files image sanitize',
328
+ resultTitle: 'Files image probe response',
329
+ path: '/health/files-image',
330
+ },
331
+ });
332
+ }
333
+
334
+ function patchApiDockerfile(targetRoot) {
335
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
336
+ if (!fs.existsSync(dockerfilePath)) {
337
+ return;
338
+ }
339
+
340
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
341
+ const packageAnchors = [
342
+ 'COPY packages/files-quotas/package.json packages/files-quotas/package.json',
343
+ 'COPY packages/files-access/package.json packages/files-access/package.json',
344
+ 'COPY packages/files/package.json packages/files/package.json',
345
+ 'COPY packages/files-local/package.json packages/files-local/package.json',
346
+ 'COPY packages/files-s3/package.json packages/files-s3/package.json',
347
+ 'COPY packages/accounts-api/package.json packages/accounts-api/package.json',
348
+ 'COPY packages/rbac/package.json packages/rbac/package.json',
349
+ 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
350
+ 'COPY packages/logger/package.json packages/logger/package.json',
351
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
352
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
353
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
354
+ 'COPY packages/core/package.json packages/core/package.json',
355
+ ];
356
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
357
+ content = ensureLineAfter(
358
+ content,
359
+ packageAnchor,
360
+ 'COPY packages/files-image/package.json packages/files-image/package.json',
361
+ );
362
+
363
+ const sourceAnchors = [
364
+ 'COPY packages/files-quotas packages/files-quotas',
365
+ 'COPY packages/files-access packages/files-access',
366
+ 'COPY packages/files packages/files',
367
+ 'COPY packages/files-local packages/files-local',
368
+ 'COPY packages/files-s3 packages/files-s3',
369
+ 'COPY packages/accounts-api packages/accounts-api',
370
+ 'COPY packages/rbac packages/rbac',
371
+ 'COPY packages/rate-limit packages/rate-limit',
372
+ 'COPY packages/logger packages/logger',
373
+ 'COPY packages/swagger packages/swagger',
374
+ 'COPY packages/i18n packages/i18n',
375
+ 'COPY packages/db-prisma packages/db-prisma',
376
+ 'COPY packages/core packages/core',
377
+ ];
378
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
379
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files-image packages/files-image');
380
+
381
+ content = content.replace(/^RUN pnpm --filter @forgeon\/files-image build\r?\n?/gm, '');
382
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/files build')
383
+ ? 'RUN pnpm --filter @forgeon/files build'
384
+ : content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
385
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
386
+ : 'RUN pnpm --filter @forgeon/api build';
387
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files-image build');
388
+
389
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
390
+ }
391
+
392
+ function patchCompose(targetRoot) {
393
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
394
+ if (!fs.existsSync(composePath)) {
395
+ return;
396
+ }
397
+
398
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
399
+ if (!content.includes('FILES_IMAGE_ENABLED: ${FILES_IMAGE_ENABLED}')) {
400
+ const anchors = [
401
+ /^(\s+FILES_QUOTA_MAX_BYTES_PER_OWNER:.*)$/m,
402
+ /^(\s+FILES_QUOTAS_ENABLED:.*)$/m,
403
+ /^(\s+FILES_ALLOWED_MIME_PREFIXES:.*)$/m,
404
+ /^(\s+FILES_MAX_FILE_SIZE_BYTES:.*)$/m,
405
+ /^(\s+FILES_PUBLIC_BASE_PATH:.*)$/m,
406
+ /^(\s+FILES_STORAGE_DRIVER:.*)$/m,
407
+ /^(\s+FILES_ENABLED:.*)$/m,
408
+ /^(\s+API_PREFIX:.*)$/m,
409
+ ];
410
+ const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
411
+ content = content.replace(
412
+ anchorPattern,
413
+ `$1
414
+ FILES_IMAGE_ENABLED: \${FILES_IMAGE_ENABLED}
415
+ FILES_IMAGE_STRIP_METADATA: \${FILES_IMAGE_STRIP_METADATA}
416
+ FILES_IMAGE_MAX_WIDTH: \${FILES_IMAGE_MAX_WIDTH}
417
+ FILES_IMAGE_MAX_HEIGHT: \${FILES_IMAGE_MAX_HEIGHT}
418
+ FILES_IMAGE_MAX_PIXELS: \${FILES_IMAGE_MAX_PIXELS}
419
+ FILES_IMAGE_MAX_FRAMES: \${FILES_IMAGE_MAX_FRAMES}
420
+ FILES_IMAGE_PROCESS_TIMEOUT_MS: \${FILES_IMAGE_PROCESS_TIMEOUT_MS}
421
+ FILES_IMAGE_ALLOWED_MIME_TYPES: \${FILES_IMAGE_ALLOWED_MIME_TYPES}`,
422
+ );
423
+ }
424
+
425
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
426
+ }
427
+
428
+ function patchReadme(targetRoot) {
429
+ const readmePath = path.join(targetRoot, 'README.md');
430
+ if (!fs.existsSync(readmePath)) {
431
+ return;
432
+ }
433
+
434
+ const marker = '## Files Image Module';
435
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
436
+ if (content.includes(marker)) {
437
+ return;
438
+ }
439
+
440
+ const section = `## Files Image Module
441
+
442
+ The files-image module adds image content validation by magic bytes and sanitize/re-encode before storage.
443
+
444
+ What it adds:
445
+ - \`@forgeon/files-image\` package (\`sharp + file-type\`)
446
+ - image pipeline in files runtime:
447
+ - detect actual type by magic bytes
448
+ - reject declared/detected mismatches
449
+ - decode -> sanitize -> re-encode
450
+ - generate optional \`preview\` file variant for image uploads
451
+ - probe endpoint: \`GET /api/health/files-image\`
452
+
453
+ Default security behavior:
454
+ - metadata is stripped before storage (\`FILES_IMAGE_STRIP_METADATA=true\`)
455
+
456
+ Key env:
457
+ - \`FILES_IMAGE_ENABLED=true\`
458
+ - \`FILES_IMAGE_STRIP_METADATA=true\`
459
+ - \`FILES_IMAGE_MAX_WIDTH=4096\`
460
+ - \`FILES_IMAGE_MAX_HEIGHT=4096\`
461
+ - \`FILES_IMAGE_MAX_PIXELS=16777216\`
462
+ - \`FILES_IMAGE_MAX_FRAMES=1\`
463
+ - \`FILES_IMAGE_PROCESS_TIMEOUT_MS=5000\`
464
+ - \`FILES_IMAGE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp\``;
465
+
466
+ if (content.includes('## Prisma In Docker Start')) {
467
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
468
+ } else {
469
+ content = `${content.trimEnd()}\n\n${section}\n`;
470
+ }
471
+
472
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
473
+ }
474
+
475
+ export function applyFilesImageModule({ packageRoot, targetRoot }) {
476
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files-image'));
477
+ const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'files-image' });
478
+
479
+ patchApiPackage(targetRoot);
480
+ patchFilesPackage(targetRoot);
481
+ patchRootPackage(targetRoot);
482
+ patchAppModule(targetRoot);
483
+ patchFilesModule(targetRoot);
484
+ patchFilesController(targetRoot);
485
+ patchFilesService(targetRoot);
486
+ patchHealthController(targetRoot, probeTargets);
487
+ registerWebProbe(targetRoot, probeTargets);
488
+ patchApiDockerfile(targetRoot);
489
+ patchCompose(targetRoot);
490
+ patchReadme(targetRoot);
491
+
492
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
493
+ 'FILES_IMAGE_ENABLED=true',
494
+ 'FILES_IMAGE_STRIP_METADATA=true',
495
+ 'FILES_IMAGE_MAX_WIDTH=4096',
496
+ 'FILES_IMAGE_MAX_HEIGHT=4096',
497
+ 'FILES_IMAGE_MAX_PIXELS=16777216',
498
+ 'FILES_IMAGE_MAX_FRAMES=1',
499
+ 'FILES_IMAGE_PROCESS_TIMEOUT_MS=5000',
500
+ 'FILES_IMAGE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp',
501
+ ]);
502
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
503
+ 'FILES_IMAGE_ENABLED=true',
504
+ 'FILES_IMAGE_STRIP_METADATA=true',
505
+ 'FILES_IMAGE_MAX_WIDTH=4096',
506
+ 'FILES_IMAGE_MAX_HEIGHT=4096',
507
+ 'FILES_IMAGE_MAX_PIXELS=16777216',
508
+ 'FILES_IMAGE_MAX_FRAMES=1',
509
+ 'FILES_IMAGE_PROCESS_TIMEOUT_MS=5000',
510
+ 'FILES_IMAGE_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp',
511
+ ]);
512
+ }
513
513
 
514
514