@hed-hog/core 0.0.370 → 0.0.371
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +2 -0
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +10 -7
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/app-command/app-command.module.d.ts +3 -0
- package/dist/app-command/app-command.module.d.ts.map +1 -0
- package/dist/app-command/app-command.module.js +22 -0
- package/dist/app-command/app-command.module.js.map +1 -0
- package/dist/app-command/app-command.registry.d.ts +22 -0
- package/dist/app-command/app-command.registry.d.ts.map +1 -0
- package/dist/{webhook-integration/webhook-command.registry.js → app-command/app-command.registry.js} +6 -6
- package/dist/app-command/app-command.registry.js.map +1 -0
- package/dist/auth/auth.controller.d.ts +2 -2
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +20 -7
- package/dist/auth/auth.controller.js.map +1 -1
- package/dist/auth/auth.service.d.ts +3 -3
- package/dist/auth/auth.service.d.ts.map +1 -1
- package/dist/auth/auth.service.js +10 -36
- package/dist/auth/auth.service.js.map +1 -1
- package/dist/auth/dto/forget.dto.d.ts +1 -0
- package/dist/auth/dto/forget.dto.d.ts.map +1 -1
- package/dist/auth/dto/forget.dto.js +5 -0
- package/dist/auth/dto/forget.dto.js.map +1 -1
- package/dist/auth/guards/auth.guard.d.ts.map +1 -1
- package/dist/auth/guards/auth.guard.js +4 -1
- package/dist/auth/guards/auth.guard.js.map +1 -1
- package/dist/challenge/challenge.service.d.ts +8 -7
- package/dist/challenge/challenge.service.d.ts.map +1 -1
- package/dist/challenge/challenge.service.js +27 -19
- package/dist/challenge/challenge.service.js.map +1 -1
- package/dist/file/dto/upload.dto.d.ts +3 -0
- package/dist/file/dto/upload.dto.d.ts.map +1 -1
- package/dist/file/dto/upload.dto.js +22 -0
- package/dist/file/dto/upload.dto.js.map +1 -1
- package/dist/file/file.controller.d.ts +2 -1
- package/dist/file/file.controller.d.ts.map +1 -1
- package/dist/file/file.controller.js +29 -16
- package/dist/file/file.controller.js.map +1 -1
- package/dist/file/file.service.d.ts +51 -1
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +250 -18
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/image-optimization.service.d.ts +17 -4
- package/dist/file/image-optimization.service.d.ts.map +1 -1
- package/dist/file/image-optimization.service.js +101 -46
- package/dist/file/image-optimization.service.js.map +1 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +15 -7
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +18 -8
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/integration/services/event-subscriber.registry.d.ts +5 -0
- package/dist/integration/services/event-subscriber.registry.d.ts.map +1 -1
- package/dist/integration/services/event-subscriber.registry.js +15 -0
- package/dist/integration/services/event-subscriber.registry.js.map +1 -1
- package/dist/integration/services/outbox.processor.d.ts.map +1 -1
- package/dist/integration/services/outbox.processor.js +2 -4
- package/dist/integration/services/outbox.processor.js.map +1 -1
- package/dist/integration/services/outbox.service.d.ts +4 -2
- package/dist/integration/services/outbox.service.d.ts.map +1 -1
- package/dist/integration/services/outbox.service.js +26 -15
- package/dist/integration/services/outbox.service.js.map +1 -1
- package/dist/integration-profile/integration-credential-crypto.service.d.ts +43 -0
- package/dist/integration-profile/integration-credential-crypto.service.d.ts.map +1 -0
- package/dist/integration-profile/integration-credential-crypto.service.js +142 -0
- package/dist/integration-profile/integration-credential-crypto.service.js.map +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts +6 -7
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.controller.js +4 -3
- package/dist/integration-profile/integration-profile.controller.js.map +1 -1
- package/dist/integration-profile/integration-profile.module.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.module.js +5 -2
- package/dist/integration-profile/integration-profile.module.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts +16 -0
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -0
- package/dist/integration-profile/integration-profile.secrets.js +56 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts +125 -8
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +257 -68
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/integration-profile.utils.d.ts +17 -0
- package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.utils.js +31 -0
- package/dist/integration-profile/integration-profile.utils.js.map +1 -1
- package/dist/language/en.json +178 -0
- package/dist/language/pt.json +178 -0
- package/dist/mail/mail.controller.d.ts.map +1 -1
- package/dist/mail/mail.controller.js +5 -28
- package/dist/mail/mail.controller.js.map +1 -1
- package/dist/mail/mail.module.js +18 -18
- package/dist/mail/mail.module.js.map +1 -1
- package/dist/mail/mail.service.d.ts +13 -2
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +25 -15
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp-chat/mcp-chat.service.js +6 -5
- package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
- package/dist/oauth/providers/facebook.provider.js +5 -5
- package/dist/oauth/providers/facebook.provider.js.map +1 -1
- package/dist/oauth/providers/github.provider.js +5 -5
- package/dist/oauth/providers/github.provider.js.map +1 -1
- package/dist/oauth/providers/google.provider.js +5 -5
- package/dist/oauth/providers/google.provider.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +5 -5
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/oauth/providers/microsoft.provider.js +5 -5
- package/dist/oauth/providers/microsoft.provider.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +4 -0
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +16 -9
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/profile/profile.controller.d.ts +7 -7
- package/dist/profile/profile.controller.d.ts.map +1 -1
- package/dist/profile/profile.controller.js +28 -21
- package/dist/profile/profile.controller.js.map +1 -1
- package/dist/profile/profile.service.d.ts +7 -7
- package/dist/profile/profile.service.d.ts.map +1 -1
- package/dist/profile/profile.service.js +21 -25
- package/dist/profile/profile.service.js.map +1 -1
- package/dist/setting/setting.service.d.ts +1 -0
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +15 -2
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/token/token.service.d.ts.map +1 -1
- package/dist/token/token.service.js +2 -1
- package/dist/token/token.service.js.map +1 -1
- package/dist/user/user.controller.d.ts +9 -0
- package/dist/user/user.controller.d.ts.map +1 -1
- package/dist/user/user.controller.js +13 -0
- package/dist/user/user.controller.js.map +1 -1
- package/dist/user/user.service.d.ts +7 -0
- package/dist/user/user.service.d.ts.map +1 -1
- package/dist/user/user.service.js +19 -0
- package/dist/user/user.service.js.map +1 -1
- package/dist/utils/webauthn-origin.d.ts +11 -0
- package/dist/utils/webauthn-origin.d.ts.map +1 -0
- package/dist/utils/webauthn-origin.js +37 -0
- package/dist/utils/webauthn-origin.js.map +1 -0
- package/dist/webhook-integration/webhook-integration.controller.d.ts +13 -0
- package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.controller.js +29 -0
- package/dist/webhook-integration/webhook-integration.controller.js.map +1 -1
- package/dist/webhook-integration/webhook-integration.module.js +3 -3
- package/dist/webhook-integration/webhook-integration.module.js.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.d.ts +18 -2
- package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.js +110 -6
- package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
- package/hedhog/data/integration_provider.yaml +9 -0
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/menu.yaml +6 -0
- package/hedhog/data/role.yaml +7 -0
- package/hedhog/data/route.yaml +36 -0
- package/hedhog/data/setting_group.yaml +119 -13
- package/hedhog/frontend/app/access-log/page.tsx.ejs +35 -3
- package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +36 -3
- package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +39 -4
- package/hedhog/frontend/app/account/components/tokens.tsx.ejs +39 -4
- package/hedhog/frontend/app/ai_agent/page.tsx.ejs +35 -2
- package/hedhog/frontend/app/configurations/[slug]/components/byte-size-input.tsx.ejs +160 -0
- package/hedhog/frontend/app/configurations/[slug]/components/duration-input.tsx.ejs +157 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +244 -63
- package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +33 -1
- package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +35 -3
- package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +36 -4
- package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +35 -3
- package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +36 -4
- package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +35 -3
- package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +141 -29
- package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +118 -11
- package/hedhog/frontend/app/mail/log/page.tsx.ejs +37 -4
- package/hedhog/frontend/app/menu/page.tsx.ejs +34 -2
- package/hedhog/frontend/app/preferences/page.tsx.ejs +77 -0
- package/hedhog/frontend/app/roles/menus.tsx.ejs +35 -3
- package/hedhog/frontend/app/roles/page.tsx.ejs +34 -2
- package/hedhog/frontend/app/roles/routes.tsx.ejs +36 -4
- package/hedhog/frontend/app/roles/users.tsx.ejs +36 -4
- package/hedhog/frontend/app/routes/page.tsx.ejs +34 -2
- package/hedhog/frontend/app/setting-list/page.tsx.ejs +34 -2
- package/hedhog/frontend/app/users/page.tsx.ejs +51 -21
- package/hedhog/frontend/messages/en.json +69 -4
- package/hedhog/frontend/messages/pt.json +69 -4
- package/hedhog/table/setting.yaml +2 -0
- package/hedhog/table/webhook_call_log.yaml +49 -46
- package/package.json +8 -3
- package/src/ai/ai.service.ts +9 -6
- package/src/app-command/app-command.module.ts +9 -0
- package/src/{webhook-integration/webhook-command.registry.ts → app-command/app-command.registry.ts} +9 -9
- package/src/auth/auth.controller.ts +20 -4
- package/src/auth/auth.service.ts +10 -36
- package/src/auth/dto/forget.dto.ts +5 -1
- package/src/auth/guards/auth.guard.ts +4 -1
- package/src/challenge/challenge.service.ts +30 -19
- package/src/file/dto/upload.dto.ts +25 -1
- package/src/file/file.controller.ts +31 -18
- package/src/file/file.service.ts +341 -16
- package/src/file/image-optimization.service.ts +111 -46
- package/src/file/provider/s3.provider.ts +16 -7
- package/src/file/provider/spaces.provider.ts +15 -5
- package/src/index.ts +4 -1
- package/src/integration/services/event-subscriber.registry.ts +15 -0
- package/src/integration/services/outbox.processor.ts +2 -5
- package/src/integration/services/outbox.service.ts +35 -17
- package/src/integration-profile/integration-credential-crypto.service.ts +146 -0
- package/src/integration-profile/integration-profile.controller.ts +2 -0
- package/src/integration-profile/integration-profile.module.ts +5 -2
- package/src/integration-profile/integration-profile.secrets.ts +51 -0
- package/src/integration-profile/integration-profile.service.ts +335 -54
- package/src/integration-profile/integration-profile.utils.ts +56 -0
- package/src/language/en.json +5 -1
- package/src/language/pt.json +5 -1
- package/src/mail/mail.controller.ts +5 -29
- package/src/mail/mail.module.ts +5 -5
- package/src/mail/mail.service.ts +37 -13
- package/src/mcp-chat/mcp-chat.service.ts +5 -5
- package/src/oauth/oauth-callback-coordinator.service.spec.ts +159 -0
- package/src/oauth/providers/facebook.provider.ts +3 -3
- package/src/oauth/providers/github.provider.ts +3 -3
- package/src/oauth/providers/google.provider.ts +3 -3
- package/src/oauth/providers/microsoft-entra-id.provider.ts +3 -3
- package/src/oauth/providers/microsoft.provider.ts +3 -3
- package/src/pdf/pdf.service.ts +19 -6
- package/src/profile/profile.controller.ts +21 -14
- package/src/profile/profile.service.ts +23 -20
- package/src/security/security.service.spec.ts +222 -0
- package/src/setting/setting.service.ts +15 -2
- package/src/token/token.service.ts +2 -1
- package/src/user/user.controller.ts +26 -16
- package/src/user/user.service.ts +25 -0
- package/src/utils/webauthn-origin.ts +38 -0
- package/src/webhook-integration/webhook-integration.controller.ts +23 -0
- package/src/webhook-integration/webhook-integration.module.ts +3 -3
- package/src/webhook-integration/webhook-integration.service.ts +115 -5
- package/dist/webhook-integration/webhook-command.registry.d.ts +0 -22
- package/dist/webhook-integration/webhook-command.registry.d.ts.map +0 -1
- package/dist/webhook-integration/webhook-command.registry.js.map +0 -1
- package/src/menu/menu.service.spec.ts +0 -247
- package/src/route/route.service.spec.ts +0 -300
- package/src/screen/screen.service.spec.ts +0 -298
- package/src/user/user.service.spec.ts +0 -294
package/src/file/file.service.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from '@nestjs/common';
|
|
33
33
|
import { JwtService } from '@nestjs/jwt';
|
|
34
34
|
import { createWriteStream, promises as fs } from 'fs';
|
|
35
|
-
import { basename, dirname } from 'path';
|
|
35
|
+
import { basename, dirname, extname } from 'path';
|
|
36
36
|
import { pipeline } from 'stream/promises';
|
|
37
37
|
import {
|
|
38
38
|
buildStorageConfigFromIntegration,
|
|
@@ -104,15 +104,14 @@ export class FileService implements OnModuleInit {
|
|
|
104
104
|
|
|
105
105
|
async getProvider(): Promise<AbstractProvider> {
|
|
106
106
|
const settings = await this.settingService.getSettingValues(['file-storage-profile-id']);
|
|
107
|
-
const
|
|
108
|
-
const profileId = Number(rawProfileId);
|
|
107
|
+
const profileSlug = String(settings['file-storage-profile-id'] ?? '').trim();
|
|
109
108
|
|
|
110
|
-
if (!
|
|
109
|
+
if (!profileSlug) {
|
|
111
110
|
throw new BadRequestException('No file storage profile configured. Set a storage profile in the File Storage settings.');
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
const profile = await this.prismaService.integration_profile.findUnique({
|
|
115
|
-
where: {
|
|
114
|
+
where: { slug: profileSlug },
|
|
116
115
|
include: {
|
|
117
116
|
integration_type: { select: { slug: true } },
|
|
118
117
|
integration_provider: { select: { slug: true } },
|
|
@@ -120,12 +119,12 @@ export class FileService implements OnModuleInit {
|
|
|
120
119
|
});
|
|
121
120
|
|
|
122
121
|
if (!profile) {
|
|
123
|
-
throw new NotFoundException(`Storage profile with
|
|
122
|
+
throw new NotFoundException(`Storage profile with slug "${profileSlug}" not found.`);
|
|
124
123
|
}
|
|
125
124
|
|
|
126
125
|
if (profile.integration_type.slug !== 'storage') {
|
|
127
126
|
throw new BadRequestException(
|
|
128
|
-
`Integration profile with
|
|
127
|
+
`Integration profile with slug "${profileSlug}" is not a storage profile.`,
|
|
129
128
|
);
|
|
130
129
|
}
|
|
131
130
|
|
|
@@ -212,17 +211,19 @@ export class FileService implements OnModuleInit {
|
|
|
212
211
|
throw new BadRequestException(`No filename provided`);
|
|
213
212
|
}
|
|
214
213
|
|
|
214
|
+
const effectiveMimetype = await this.resolveEffectiveMimetype(mimetype, filename);
|
|
215
|
+
|
|
215
216
|
const provider = await this.getProvider();
|
|
216
217
|
|
|
217
|
-
if (!(await this.acceptMimetypes(
|
|
218
|
-
throw new BadRequestException(`Invalid file type: ${
|
|
218
|
+
if (!(await this.acceptMimetypes(effectiveMimetype))) {
|
|
219
|
+
throw new BadRequestException(`Invalid file type: ${effectiveMimetype}`);
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
const url = await provider.uploadFromString(
|
|
222
223
|
destination,
|
|
223
224
|
filename,
|
|
224
225
|
filecontent,
|
|
225
|
-
|
|
226
|
+
effectiveMimetype,
|
|
226
227
|
);
|
|
227
228
|
|
|
228
229
|
const file = await this.prismaService.file.create({
|
|
@@ -231,7 +232,7 @@ export class FileService implements OnModuleInit {
|
|
|
231
232
|
path: url,
|
|
232
233
|
provider_id: this.providerId,
|
|
233
234
|
location: destination,
|
|
234
|
-
mimetype_id: await this.getMimeType(
|
|
235
|
+
mimetype_id: await this.getMimeType(effectiveMimetype),
|
|
235
236
|
size: filecontent.length,
|
|
236
237
|
},
|
|
237
238
|
});
|
|
@@ -425,7 +426,11 @@ export class FileService implements OnModuleInit {
|
|
|
425
426
|
res.send(buffer);
|
|
426
427
|
}
|
|
427
428
|
|
|
428
|
-
async upload(
|
|
429
|
+
async upload(
|
|
430
|
+
destination: string,
|
|
431
|
+
fileBuffer: MulterFile,
|
|
432
|
+
resize?: { maxWidth?: number; maxHeight?: number },
|
|
433
|
+
) {
|
|
429
434
|
|
|
430
435
|
if (!fileBuffer) {
|
|
431
436
|
throw new BadRequestException(`No file provided`);
|
|
@@ -451,10 +456,17 @@ export class FileService implements OnModuleInit {
|
|
|
451
456
|
fileBuffer.originalname,
|
|
452
457
|
);
|
|
453
458
|
|
|
459
|
+
const effectiveMimetype = await this.resolveEffectiveMimetype(
|
|
460
|
+
fileBuffer.mimetype,
|
|
461
|
+
normalizedFilename,
|
|
462
|
+
fileBuffer.buffer,
|
|
463
|
+
);
|
|
464
|
+
|
|
454
465
|
const optimized = await this.imageOptimizationService.optimizeBuffer(
|
|
455
466
|
fileBuffer.buffer,
|
|
456
|
-
|
|
467
|
+
effectiveMimetype,
|
|
457
468
|
normalizedFilename,
|
|
469
|
+
{ maxWidth: resize?.maxWidth, maxHeight: resize?.maxHeight },
|
|
458
470
|
);
|
|
459
471
|
|
|
460
472
|
const normalizedFile = {
|
|
@@ -491,6 +503,7 @@ export class FileService implements OnModuleInit {
|
|
|
491
503
|
size?: number;
|
|
492
504
|
encoding?: string;
|
|
493
505
|
skipMimeValidation?: boolean;
|
|
506
|
+
skipSizeValidation?: boolean;
|
|
494
507
|
},
|
|
495
508
|
) {
|
|
496
509
|
if (!filePath) {
|
|
@@ -507,19 +520,49 @@ export class FileService implements OnModuleInit {
|
|
|
507
520
|
}
|
|
508
521
|
|
|
509
522
|
const size = metadata?.size ?? stats.size;
|
|
510
|
-
const mimetype = metadata?.mimetype ?? 'application/octet-stream';
|
|
511
523
|
const originalname = this.normalizeUploadedFilename(
|
|
512
524
|
metadata?.originalname ?? metadata?.filename ?? basename(filePath),
|
|
513
525
|
);
|
|
526
|
+
// Recupera o tipo real quando o caller não informou o mimetype (default antigo era
|
|
527
|
+
// application/octet-stream, que fazia imagens caírem fora da otimização e do serve).
|
|
528
|
+
const mimetype = await this.resolveEffectiveMimetype(
|
|
529
|
+
metadata?.mimetype,
|
|
530
|
+
originalname,
|
|
531
|
+
);
|
|
514
532
|
|
|
515
533
|
if (!metadata?.skipMimeValidation && !(await this.acceptMimetypes(mimetype))) {
|
|
516
534
|
throw new BadRequestException(`Invalid file type: ${mimetype}`);
|
|
517
535
|
}
|
|
518
536
|
|
|
519
|
-
if (!(await this.maxFileSize(size))) {
|
|
537
|
+
if (!metadata?.skipSizeValidation && !(await this.maxFileSize(size))) {
|
|
520
538
|
throw new BadRequestException(`File too large: ${size} bytes`);
|
|
521
539
|
}
|
|
522
540
|
|
|
541
|
+
const provider = await this.getProvider();
|
|
542
|
+
|
|
543
|
+
// Para não-imagens (ex.: pacotes SCORM .zip de vários GB), enviamos por
|
|
544
|
+
// streaming direto do disco via provider.uploadFromPath. Isso evita carregar
|
|
545
|
+
// o arquivo inteiro na RAM (fs.readFile estoura o limite de Buffer / causa OOM)
|
|
546
|
+
// e habilita upload multipart no S3. A otimização de imagem só se aplica a
|
|
547
|
+
// image/*, então não há perda nesse caminho.
|
|
548
|
+
if (!mimetype.startsWith('image/')) {
|
|
549
|
+
const url = await provider.uploadFromPath(destination, filePath, {
|
|
550
|
+
filename: originalname,
|
|
551
|
+
mimetype,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return this.prismaService.file.create({
|
|
555
|
+
data: {
|
|
556
|
+
filename: originalname,
|
|
557
|
+
path: url,
|
|
558
|
+
provider_id: this.providerId,
|
|
559
|
+
location: destination,
|
|
560
|
+
mimetype_id: await this.getMimeType(mimetype),
|
|
561
|
+
size,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
523
566
|
// Read buffer to allow image optimization regardless of provider support
|
|
524
567
|
const rawBuffer = await fs.readFile(filePath);
|
|
525
568
|
const optimized = await this.imageOptimizationService.optimizeBuffer(
|
|
@@ -528,7 +571,6 @@ export class FileService implements OnModuleInit {
|
|
|
528
571
|
originalname,
|
|
529
572
|
);
|
|
530
573
|
|
|
531
|
-
const provider = await this.getProvider();
|
|
532
574
|
const url = await provider.upload(destination, {
|
|
533
575
|
fieldname: 'file',
|
|
534
576
|
originalname: optimized.originalname,
|
|
@@ -555,6 +597,95 @@ export class FileService implements OnModuleInit {
|
|
|
555
597
|
return file;
|
|
556
598
|
}
|
|
557
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Re-applies the current image-optimization settings to an already-stored file,
|
|
602
|
+
* rewriting it in place: the `file` record keeps the same id, so every row that
|
|
603
|
+
* references it (course_image, course_lesson_video_frame, ...) stays valid without
|
|
604
|
+
* any FK juggling. Only replaces the stored object when the optimized result is
|
|
605
|
+
* strictly smaller or the mimetype changed (e.g. PNG → WebP) — this keeps the
|
|
606
|
+
* operation idempotent and avoids repeated lossy recompression. Non-image files are
|
|
607
|
+
* left untouched.
|
|
608
|
+
*/
|
|
609
|
+
async reoptimizeFile(fileId: number): Promise<{
|
|
610
|
+
changed: boolean;
|
|
611
|
+
oldSize: number;
|
|
612
|
+
newSize: number;
|
|
613
|
+
mimetypeChanged: boolean;
|
|
614
|
+
}> {
|
|
615
|
+
const { file, buffer } = await this.getBuffer(fileId);
|
|
616
|
+
const currentMimetype = file.file_mimetype.name;
|
|
617
|
+
const oldSize = file.size ?? buffer.length;
|
|
618
|
+
|
|
619
|
+
if (!currentMimetype.startsWith('image/')) {
|
|
620
|
+
return { changed: false, oldSize, newSize: oldSize, mimetypeChanged: false };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const optimized = await this.imageOptimizationService.optimizeBuffer(
|
|
624
|
+
buffer,
|
|
625
|
+
currentMimetype,
|
|
626
|
+
file.filename,
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const mimetypeChanged = optimized.mimetype !== currentMimetype;
|
|
630
|
+
const smaller = optimized.buffer.length < oldSize;
|
|
631
|
+
|
|
632
|
+
if (!mimetypeChanged && !smaller) {
|
|
633
|
+
return {
|
|
634
|
+
changed: false,
|
|
635
|
+
oldSize,
|
|
636
|
+
newSize: optimized.buffer.length,
|
|
637
|
+
mimetypeChanged: false,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const provider = await this.getProvider();
|
|
642
|
+
const destination = file.location;
|
|
643
|
+
const oldPath = file.path;
|
|
644
|
+
|
|
645
|
+
const newUrl = await provider.upload(destination, {
|
|
646
|
+
fieldname: 'file',
|
|
647
|
+
originalname: optimized.originalname,
|
|
648
|
+
encoding: '7bit',
|
|
649
|
+
mimetype: optimized.mimetype,
|
|
650
|
+
size: optimized.buffer.length,
|
|
651
|
+
destination: '',
|
|
652
|
+
filename: optimized.originalname,
|
|
653
|
+
path: '',
|
|
654
|
+
buffer: optimized.buffer,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
await this.prismaService.file.update({
|
|
658
|
+
where: { id: fileId },
|
|
659
|
+
data: {
|
|
660
|
+
path: newUrl,
|
|
661
|
+
filename: optimized.originalname,
|
|
662
|
+
size: optimized.buffer.length,
|
|
663
|
+
mimetype_id: await this.getMimeType(optimized.mimetype),
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Best-effort cleanup of the previous stored object; an orphaned blob must not
|
|
668
|
+
// fail the optimization since the DB already points at the new object.
|
|
669
|
+
const oldResolved = this.resolveProviderPath({ location: destination, path: oldPath });
|
|
670
|
+
const newResolved = this.resolveProviderPath({ location: destination, path: newUrl });
|
|
671
|
+
if (oldResolved !== newResolved) {
|
|
672
|
+
try {
|
|
673
|
+
await provider.delete(oldResolved);
|
|
674
|
+
} catch (err) {
|
|
675
|
+
this.logger.warn(
|
|
676
|
+
`reoptimizeFile(${fileId}): failed to delete previous object "${oldResolved}": ${err instanceof Error ? err.message : String(err)}`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
changed: true,
|
|
683
|
+
oldSize,
|
|
684
|
+
newSize: optimized.buffer.length,
|
|
685
|
+
mimetypeChanged,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
558
689
|
async delete(locale:string,{ ids }: DeleteDTO) {
|
|
559
690
|
const files = await this.prismaService.file.findMany({
|
|
560
691
|
where: {
|
|
@@ -712,6 +843,200 @@ export class FileService implements OnModuleInit {
|
|
|
712
843
|
};
|
|
713
844
|
}
|
|
714
845
|
|
|
846
|
+
private async getCacheTtl(slug: string, fallback: number): Promise<number> {
|
|
847
|
+
const settings = await this.settingService.getSettingValues(slug);
|
|
848
|
+
const value = Number(settings[slug]);
|
|
849
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Resolves the file record and its storage metadata (mimetype + size) without
|
|
854
|
+
* opening a read stream — so callers can answer a conditional 304 cheaply.
|
|
855
|
+
*/
|
|
856
|
+
private async resolveFileMeta(locale: string, id: number) {
|
|
857
|
+
const file = await this.prismaService.file.findFirst({
|
|
858
|
+
where: { id },
|
|
859
|
+
include: { file_mimetype: true },
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
if (!file) {
|
|
863
|
+
throw new NotFoundException(
|
|
864
|
+
getLocaleText('file.download.not_found', locale, `Not found: ${id}`),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const provider = await this.getProvider();
|
|
869
|
+
const providerPath = this.resolveProviderPath(file);
|
|
870
|
+
const metadata = await provider.metaData(providerPath);
|
|
871
|
+
const size = this.getFileSizeFromMetadata(metadata);
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
provider,
|
|
875
|
+
providerPath,
|
|
876
|
+
size,
|
|
877
|
+
mimetype: file?.file_mimetype?.name || 'application/octet-stream',
|
|
878
|
+
filename: file?.filename || 'download',
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private setInlineHeaders(
|
|
883
|
+
res: any,
|
|
884
|
+
filename: string,
|
|
885
|
+
mimetype: string,
|
|
886
|
+
size: number | undefined,
|
|
887
|
+
cacheControl: string,
|
|
888
|
+
etag: string,
|
|
889
|
+
) {
|
|
890
|
+
const encodedFilename = encodeURIComponent(filename);
|
|
891
|
+
res.set({
|
|
892
|
+
'Content-Type': mimetype || 'application/octet-stream',
|
|
893
|
+
'Content-Disposition':
|
|
894
|
+
`inline; filename="${filename}"; filename*=UTF-8''${encodedFilename}`,
|
|
895
|
+
'Cache-Control': cacheControl,
|
|
896
|
+
ETag: etag,
|
|
897
|
+
});
|
|
898
|
+
if (typeof size === 'number') {
|
|
899
|
+
res.set('Content-Length', `${size}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Best-effort image MIME type from a filename/key extension. Used to serve images
|
|
905
|
+
* whose stored mimetype is generic (e.g. application/octet-stream from imports or
|
|
906
|
+
* extractions) with a correct Content-Type instead of rejecting them.
|
|
907
|
+
*/
|
|
908
|
+
private inferImageContentType(nameOrPath?: string): string | null {
|
|
909
|
+
if (!nameOrPath) return null;
|
|
910
|
+
const ext = extname(nameOrPath).toLowerCase().replace(/^\./, '');
|
|
911
|
+
const map: Record<string, string> = {
|
|
912
|
+
jpg: 'image/jpeg',
|
|
913
|
+
jpeg: 'image/jpeg',
|
|
914
|
+
png: 'image/png',
|
|
915
|
+
webp: 'image/webp',
|
|
916
|
+
gif: 'image/gif',
|
|
917
|
+
svg: 'image/svg+xml',
|
|
918
|
+
bmp: 'image/bmp',
|
|
919
|
+
tif: 'image/tiff',
|
|
920
|
+
tiff: 'image/tiff',
|
|
921
|
+
avif: 'image/avif',
|
|
922
|
+
ico: 'image/x-icon',
|
|
923
|
+
heic: 'image/heic',
|
|
924
|
+
heif: 'image/heif',
|
|
925
|
+
};
|
|
926
|
+
return map[ext] ?? null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Resolves the MIME type to persist for an upload. When the incoming mimetype is
|
|
931
|
+
* generic (application/octet-stream or empty), recovers the real image type from
|
|
932
|
+
* the filename extension and, when raw bytes are available, by sniffing with sharp.
|
|
933
|
+
* Concrete non-image types (video, pdf, ...) are returned unchanged.
|
|
934
|
+
*/
|
|
935
|
+
private async resolveEffectiveMimetype(
|
|
936
|
+
mimetype: string | undefined,
|
|
937
|
+
name: string,
|
|
938
|
+
buffer?: Buffer,
|
|
939
|
+
): Promise<string> {
|
|
940
|
+
if (mimetype && mimetype.startsWith('image/')) return mimetype;
|
|
941
|
+
// Tipos concretos não-imagem (video, pdf, ...) são mantidos como estão.
|
|
942
|
+
if (mimetype && mimetype !== 'application/octet-stream') return mimetype;
|
|
943
|
+
|
|
944
|
+
// Aqui o mimetype é vazio/undefined ou application/octet-stream: recupera o real.
|
|
945
|
+
const byExtension = this.inferImageContentType(name);
|
|
946
|
+
if (byExtension) return byExtension;
|
|
947
|
+
|
|
948
|
+
if (buffer) {
|
|
949
|
+
const sniffed = await this.imageOptimizationService.detectImageMimetype(buffer);
|
|
950
|
+
if (sniffed) return sniffed;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return mimetype ?? 'application/octet-stream';
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Serves an image by id with strong, immutable HTTP caching. The content of a
|
|
958
|
+
* given fileId never changes (a new upload yields a new id), so the ETag is
|
|
959
|
+
* derived from id+size instead of hashing the buffer, keeping it streaming.
|
|
960
|
+
*/
|
|
961
|
+
async serveImageById(
|
|
962
|
+
locale: string,
|
|
963
|
+
id: number,
|
|
964
|
+
ifNoneMatch: string | undefined,
|
|
965
|
+
res: any,
|
|
966
|
+
) {
|
|
967
|
+
const { provider, providerPath, size, mimetype, filename } =
|
|
968
|
+
await this.resolveFileMeta(locale, id);
|
|
969
|
+
|
|
970
|
+
// Many legacy/imported files store a generic mimetype (application/octet-stream).
|
|
971
|
+
// Since this endpoint serves images referenced by <img> tags, infer the
|
|
972
|
+
// Content-Type from the extension instead of rejecting; only block concretely
|
|
973
|
+
// non-image types (pdf, video, ...).
|
|
974
|
+
const contentType = mimetype.startsWith('image/')
|
|
975
|
+
? mimetype
|
|
976
|
+
: (this.inferImageContentType(filename) ??
|
|
977
|
+
this.inferImageContentType(providerPath) ??
|
|
978
|
+
mimetype);
|
|
979
|
+
|
|
980
|
+
const isGenericBinary = !mimetype || mimetype === 'application/octet-stream';
|
|
981
|
+
if (!contentType.startsWith('image/') && !isGenericBinary) {
|
|
982
|
+
throw new NotFoundException(
|
|
983
|
+
getLocaleText('file.download.not_found', locale, `Not an image: ${id}`),
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const etag = `"img-${id}-${size ?? 0}"`;
|
|
988
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
989
|
+
res.status(304).end();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const ttl = await this.getCacheTtl('image-cache-ttl-seconds', 2592000);
|
|
994
|
+
this.setInlineHeaders(
|
|
995
|
+
res,
|
|
996
|
+
filename,
|
|
997
|
+
contentType,
|
|
998
|
+
size,
|
|
999
|
+
`public, max-age=${ttl}, immutable`,
|
|
1000
|
+
etag,
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const stream = await provider.readStream(providerPath);
|
|
1004
|
+
stream.pipe(res);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Serves any file by id with configurable HTTP caching (not immutable, shorter
|
|
1009
|
+
* TTL than images). Used by the generic open endpoint's numeric-id branch.
|
|
1010
|
+
*/
|
|
1011
|
+
async serveFileById(
|
|
1012
|
+
locale: string,
|
|
1013
|
+
id: number,
|
|
1014
|
+
ifNoneMatch: string | undefined,
|
|
1015
|
+
res: any,
|
|
1016
|
+
) {
|
|
1017
|
+
const { provider, providerPath, size, mimetype, filename } =
|
|
1018
|
+
await this.resolveFileMeta(locale, id);
|
|
1019
|
+
|
|
1020
|
+
const etag = `"file-${id}-${size ?? 0}"`;
|
|
1021
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
1022
|
+
res.status(304).end();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const ttl = await this.getCacheTtl('file-cache-ttl-seconds', 86400);
|
|
1027
|
+
this.setInlineHeaders(
|
|
1028
|
+
res,
|
|
1029
|
+
filename,
|
|
1030
|
+
mimetype,
|
|
1031
|
+
size,
|
|
1032
|
+
`public, max-age=${ttl}`,
|
|
1033
|
+
etag,
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
const stream = await provider.readStream(providerPath);
|
|
1037
|
+
stream.pipe(res);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
715
1040
|
async tempURL(filepath: string, expires = 3600) {
|
|
716
1041
|
return (await this.getProvider()).tempURL(filepath, expires);
|
|
717
1042
|
}
|
|
@@ -9,6 +9,12 @@ export interface OptimizedImage {
|
|
|
9
9
|
originalname: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface OptimizeOptions {
|
|
13
|
+
/** Precise target dimensions (e.g. from a cropped course banner/logo/square). */
|
|
14
|
+
maxWidth?: number;
|
|
15
|
+
maxHeight?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
@Injectable()
|
|
13
19
|
export class ImageOptimizationService {
|
|
14
20
|
private readonly logger = new Logger(ImageOptimizationService.name);
|
|
@@ -33,15 +39,47 @@ export class ImageOptimizationService {
|
|
|
33
39
|
return `${base}.${newExt}`;
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
private pngCompressionLevel(quality: number): number {
|
|
43
|
+
// sharp compressionLevel is 0-9 (9 = max); invert from quality scale
|
|
44
|
+
return Math.round(((100 - quality) / 100) * 9);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detects the image MIME type from the raw bytes using sharp. Returns null when
|
|
49
|
+
* the buffer is not a recognizable image (or sharp fails). Used to recover the
|
|
50
|
+
* real type of files uploaded with a generic mimetype (application/octet-stream).
|
|
51
|
+
*/
|
|
52
|
+
async detectImageMimetype(buffer: Buffer): Promise<string | null> {
|
|
53
|
+
try {
|
|
54
|
+
const { format } = await sharp(buffer).metadata();
|
|
55
|
+
const map: Record<string, string> = {
|
|
56
|
+
jpeg: 'image/jpeg',
|
|
57
|
+
jpg: 'image/jpeg',
|
|
58
|
+
png: 'image/png',
|
|
59
|
+
webp: 'image/webp',
|
|
60
|
+
gif: 'image/gif',
|
|
61
|
+
svg: 'image/svg+xml',
|
|
62
|
+
tiff: 'image/tiff',
|
|
63
|
+
avif: 'image/avif',
|
|
64
|
+
heif: 'image/heif',
|
|
65
|
+
};
|
|
66
|
+
return format ? (map[format] ?? null) : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
36
72
|
/**
|
|
37
|
-
* Applies
|
|
38
|
-
* based on the current settings. Returns the original data unchanged
|
|
39
|
-
*
|
|
73
|
+
* Applies optional resize, compression and/or WebP conversion to the given
|
|
74
|
+
* buffer based on the current settings. Returns the original data unchanged
|
|
75
|
+
* when there is nothing to do (no resize and the MIME type is not in any
|
|
76
|
+
* configured optimization list).
|
|
40
77
|
*/
|
|
41
78
|
async optimizeBuffer(
|
|
42
79
|
buffer: Buffer,
|
|
43
80
|
mimetype: string,
|
|
44
81
|
originalname: string,
|
|
82
|
+
options: OptimizeOptions = {},
|
|
45
83
|
): Promise<OptimizedImage> {
|
|
46
84
|
if (!mimetype.startsWith('image/')) {
|
|
47
85
|
return { buffer, mimetype, originalname };
|
|
@@ -53,71 +91,98 @@ export class ImageOptimizationService {
|
|
|
53
91
|
'image-compression-types',
|
|
54
92
|
'image-webp-conversion-enabled',
|
|
55
93
|
'image-webp-convert-types',
|
|
94
|
+
'image-max-dimension',
|
|
56
95
|
]);
|
|
57
96
|
|
|
58
97
|
const compressionEnabled = settings['image-compression-enabled'] === 'true';
|
|
59
98
|
const webpEnabled = settings['image-webp-conversion-enabled'] === 'true';
|
|
60
99
|
|
|
61
|
-
if (!compressionEnabled && !webpEnabled) {
|
|
62
|
-
return { buffer, mimetype, originalname };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
100
|
const quality = Math.min(
|
|
66
101
|
100,
|
|
67
102
|
Math.max(1, Number(settings['image-compression-quality']) || 80),
|
|
68
103
|
);
|
|
69
104
|
const compressionTypes = this.parseArraySetting(settings['image-compression-types']);
|
|
70
105
|
const webpConvertTypes = this.parseArraySetting(settings['image-webp-convert-types']);
|
|
106
|
+
const maxDimension = Math.max(0, Number(settings['image-max-dimension']) || 0);
|
|
107
|
+
|
|
108
|
+
// Resolve the resize operation: a precise target (cropped uploads) wins over
|
|
109
|
+
// the generic max-dimension cap.
|
|
110
|
+
const targetWidth = Number(options.maxWidth) || 0;
|
|
111
|
+
const targetHeight = Number(options.maxHeight) || 0;
|
|
112
|
+
|
|
113
|
+
let resize: sharp.ResizeOptions | null = null;
|
|
114
|
+
if (targetWidth > 0 && targetHeight > 0) {
|
|
115
|
+
resize = { width: targetWidth, height: targetHeight, fit: 'cover' };
|
|
116
|
+
} else if (maxDimension > 0) {
|
|
117
|
+
resize = {
|
|
118
|
+
width: maxDimension,
|
|
119
|
+
height: maxDimension,
|
|
120
|
+
fit: 'inside',
|
|
121
|
+
withoutEnlargement: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const willConvertWebp = webpEnabled && webpConvertTypes.includes(mimetype);
|
|
126
|
+
const willCompress = compressionEnabled && compressionTypes.includes(mimetype);
|
|
71
127
|
|
|
72
|
-
//
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
128
|
+
// Nothing to do for this image.
|
|
129
|
+
if (!resize && !willConvertWebp && !willCompress) {
|
|
130
|
+
return { buffer, mimetype, originalname };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
let instance = sharp(buffer);
|
|
135
|
+
if (resize) {
|
|
136
|
+
instance = instance.resize(resize);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// WebP conversion has priority over plain compression.
|
|
140
|
+
if (willConvertWebp) {
|
|
141
|
+
const optimized = await instance.webp({ quality }).toBuffer();
|
|
76
142
|
return {
|
|
77
143
|
buffer: optimized,
|
|
78
144
|
mimetype: 'image/webp',
|
|
79
145
|
originalname: this.replaceExtension(originalname, 'webp'),
|
|
80
146
|
};
|
|
81
|
-
} catch (err) {
|
|
82
|
-
this.logger.warn(
|
|
83
|
-
`WebP conversion failed for "${originalname}" (${mimetype}): ${err instanceof Error ? err.message : String(err)}. Falling back to original.`,
|
|
84
|
-
);
|
|
85
|
-
return { buffer, mimetype, originalname };
|
|
86
147
|
}
|
|
87
|
-
}
|
|
88
148
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
149
|
+
// Re-encode keeping the original format. Reached when compressing, or when
|
|
150
|
+
// only resizing (a resize always requires re-encoding the buffer).
|
|
151
|
+
if (mimetype === 'image/jpeg') {
|
|
152
|
+
const optimized = await instance.jpeg({ quality }).toBuffer();
|
|
153
|
+
return { buffer: optimized, mimetype, originalname };
|
|
154
|
+
} else if (mimetype === 'image/png') {
|
|
155
|
+
const optimized = await instance
|
|
156
|
+
.png({ compressionLevel: this.pngCompressionLevel(quality) })
|
|
157
|
+
.toBuffer();
|
|
158
|
+
return { buffer: optimized, mimetype, originalname };
|
|
159
|
+
} else if (mimetype === 'image/webp') {
|
|
160
|
+
const optimized = await instance.webp({ quality }).toBuffer();
|
|
161
|
+
return { buffer: optimized, mimetype, originalname };
|
|
162
|
+
} else if (mimetype === 'image/bmp' || mimetype === 'image/tiff') {
|
|
163
|
+
// Convert to PNG as a compressed lossless alternative.
|
|
164
|
+
const optimized = await instance
|
|
165
|
+
.png({ compressionLevel: this.pngCompressionLevel(quality) })
|
|
166
|
+
.toBuffer();
|
|
167
|
+
return {
|
|
168
|
+
buffer: optimized,
|
|
169
|
+
mimetype: 'image/png',
|
|
170
|
+
originalname: this.replaceExtension(originalname, 'png'),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Unknown image format: only re-encode if a resize was requested,
|
|
175
|
+
// otherwise leave it untouched.
|
|
176
|
+
if (resize) {
|
|
111
177
|
const optimized = await instance.toBuffer();
|
|
112
178
|
return { buffer: optimized, mimetype, originalname };
|
|
113
|
-
} catch (err) {
|
|
114
|
-
this.logger.warn(
|
|
115
|
-
`Image compression failed for "${originalname}" (${mimetype}): ${err instanceof Error ? err.message : String(err)}. Falling back to original.`,
|
|
116
|
-
);
|
|
117
|
-
return { buffer, mimetype, originalname };
|
|
118
179
|
}
|
|
180
|
+
return { buffer, mimetype, originalname };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.logger.warn(
|
|
183
|
+
`Image optimization failed for "${originalname}" (${mimetype}): ${err instanceof Error ? err.message : String(err)}. Falling back to original.`,
|
|
184
|
+
);
|
|
185
|
+
return { buffer, mimetype, originalname };
|
|
119
186
|
}
|
|
120
|
-
|
|
121
|
-
return { buffer, mimetype, originalname };
|
|
122
187
|
}
|
|
123
188
|
}
|