@hed-hog/core 0.0.361 → 0.0.362

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/access-log/access-log.controller.d.ts +1 -1
  2. package/dist/access-log/access-log.service.d.ts +1 -1
  3. package/dist/access-log/access-log.service.d.ts.map +1 -1
  4. package/dist/access-log/access-log.service.js +43 -1
  5. package/dist/access-log/access-log.service.js.map +1 -1
  6. package/dist/auth/auth.controller.d.ts +1 -0
  7. package/dist/auth/auth.controller.d.ts.map +1 -1
  8. package/dist/auth/auth.controller.js +6 -2
  9. package/dist/auth/auth.controller.js.map +1 -1
  10. package/dist/auth/auth.service.d.ts +1 -0
  11. package/dist/auth/auth.service.d.ts.map +1 -1
  12. package/dist/auth/auth.service.js +15 -5
  13. package/dist/auth/auth.service.js.map +1 -1
  14. package/dist/auth/guards/auth.guard.d.ts +1 -0
  15. package/dist/auth/guards/auth.guard.d.ts.map +1 -1
  16. package/dist/auth/guards/auth.guard.js +11 -4
  17. package/dist/auth/guards/auth.guard.js.map +1 -1
  18. package/dist/file/file.module.d.ts.map +1 -1
  19. package/dist/file/file.module.js +3 -2
  20. package/dist/file/file.module.js.map +1 -1
  21. package/dist/file/file.service.d.ts +71 -1
  22. package/dist/file/file.service.d.ts.map +1 -1
  23. package/dist/file/file.service.js +180 -32
  24. package/dist/file/file.service.js.map +1 -1
  25. package/dist/file/image-optimization.service.d.ts +20 -0
  26. package/dist/file/image-optimization.service.d.ts.map +1 -0
  27. package/dist/file/image-optimization.service.js +126 -0
  28. package/dist/file/image-optimization.service.js.map +1 -0
  29. package/dist/file/provider/local.provider.d.ts +1 -1
  30. package/dist/file/provider/local.provider.d.ts.map +1 -1
  31. package/dist/file/provider/local.provider.js +2 -9
  32. package/dist/file/provider/local.provider.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/mcp/tools/core-users.mcp-tools.d.ts +1 -1
  38. package/dist/mcp/tools/core-users.mcp-tools.d.ts.map +1 -1
  39. package/dist/mcp/tools/core-users.mcp-tools.js +2 -2
  40. package/dist/mcp/tools/core-users.mcp-tools.js.map +1 -1
  41. package/dist/profile/profile.service.d.ts.map +1 -1
  42. package/dist/profile/profile.service.js +44 -23
  43. package/dist/profile/profile.service.js.map +1 -1
  44. package/dist/session/session.service.d.ts.map +1 -1
  45. package/dist/session/session.service.js +7 -2
  46. package/dist/session/session.service.js.map +1 -1
  47. package/dist/token/token.service.d.ts +1 -0
  48. package/dist/token/token.service.d.ts.map +1 -1
  49. package/dist/token/token.service.js +23 -6
  50. package/dist/token/token.service.js.map +1 -1
  51. package/dist/user/user.controller.d.ts +1 -1
  52. package/dist/user/user.controller.d.ts.map +1 -1
  53. package/dist/user/user.controller.js +4 -3
  54. package/dist/user/user.controller.js.map +1 -1
  55. package/dist/user/user.service.d.ts +1 -1
  56. package/dist/user/user.service.d.ts.map +1 -1
  57. package/dist/user/user.service.js +6 -6
  58. package/dist/user/user.service.js.map +1 -1
  59. package/hedhog/data/route.yaml +2 -0
  60. package/hedhog/data/setting_group.yaml +117 -0
  61. package/hedhog/data/setting_subgroup.yaml +8 -0
  62. package/hedhog/frontend/app/access-log/page.tsx.ejs +170 -11
  63. package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +1 -1
  64. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +1 -1
  65. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +112 -5
  66. package/hedhog/frontend/app/users/page.tsx.ejs +7 -7
  67. package/hedhog/frontend/app/users/permissions.tsx.ejs +137 -83
  68. package/hedhog/frontend/app/users/user-access-sheet.tsx.ejs +28 -26
  69. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +207 -75
  70. package/hedhog/frontend/messages/en.json +9 -2
  71. package/hedhog/frontend/messages/pt.json +9 -2
  72. package/package.json +11 -6
  73. package/src/access-log/access-log.service.ts +19 -1
  74. package/src/auth/auth.controller.ts +5 -0
  75. package/src/auth/auth.service.ts +14 -4
  76. package/src/auth/guards/auth.guard.ts +10 -3
  77. package/src/file/file.module.ts +3 -2
  78. package/src/file/file.service.ts +361 -47
  79. package/src/file/image-optimization.service.ts +123 -0
  80. package/src/file/provider/local.provider.ts +2 -9
  81. package/src/index.ts +1 -0
  82. package/src/mcp/tools/core-users.mcp-tools.ts +267 -267
  83. package/src/profile/profile.service.ts +40 -22
  84. package/src/session/session.service.ts +10 -2
  85. package/src/token/token.service.ts +26 -6
  86. package/src/user/user.controller.ts +5 -2
  87. package/src/user/user.service.ts +11 -8
  88. package/dist/menu/menu.service.spec.d.ts +0 -1
  89. package/dist/menu/menu.service.spec.d.ts.map +0 -1
  90. package/dist/menu/menu.service.spec.js +0 -208
  91. package/dist/menu/menu.service.spec.js.map +0 -1
  92. package/dist/route/route.service.spec.d.ts +0 -1
  93. package/dist/route/route.service.spec.d.ts.map +0 -1
  94. package/dist/route/route.service.spec.js +0 -262
  95. package/dist/route/route.service.spec.js.map +0 -1
  96. package/dist/screen/screen.service.spec.d.ts +0 -1
  97. package/dist/screen/screen.service.spec.d.ts.map +0 -1
  98. package/dist/screen/screen.service.spec.js +0 -259
  99. package/dist/screen/screen.service.spec.js.map +0 -1
  100. package/dist/user/user.service.spec.d.ts +0 -1
  101. package/dist/user/user.service.spec.d.ts.map +0 -1
  102. package/dist/user/user.service.spec.js +0 -261
  103. package/dist/user/user.service.spec.js.map +0 -1
@@ -5,6 +5,7 @@ import {
5
5
  CanActivate,
6
6
  ExecutionContext,
7
7
  Injectable,
8
+ Logger,
8
9
  UnauthorizedException,
9
10
  } from '@nestjs/common';
10
11
  import { Reflector } from '@nestjs/core';
@@ -14,6 +15,8 @@ import { TokenService } from '../../token/token.service';
14
15
 
15
16
  @Injectable()
16
17
  export class AuthGuard implements CanActivate {
18
+ private readonly logger = new Logger(AuthGuard.name);
19
+
17
20
  constructor(
18
21
  private token: TokenService,
19
22
  private reflector: Reflector,
@@ -50,23 +53,26 @@ export class AuthGuard implements CanActivate {
50
53
  if (isPublic) {
51
54
  return true;
52
55
  } else if (withNoRole) {
56
+ this.logger.debug(`[AuthGuard] No token: ${request.method} ${request.url}`);
53
57
  // @NoRole() requires authentication but no specific role — token is mandatory
54
58
  throw new UnauthorizedException(
55
59
  getLocaleText('accessDenied', locale, 'Access denied.'),
56
60
  );
57
61
  } else if (withRole) {
62
+ this.logger.debug(`[AuthGuard] No token: ${request.method} ${request.url}`);
58
63
  // @Role() decorator requires authentication
59
64
  throw new UnauthorizedException(
60
65
  getLocaleText('accessDenied', locale, 'Access denied.'),
61
66
  );
62
67
  } else {
68
+ this.logger.debug(`[AuthGuard] No token: ${request.method} ${request.url}`);
63
69
  // No explicit decorator, default behavior (deny)
64
70
  throw new UnauthorizedException(
65
71
  getLocaleText('accessDenied', locale, 'Access denied.'),
66
72
  );
67
73
  }
68
74
  }
69
-
75
+
70
76
  try {
71
77
  const payload = await this.token.verify(locale, token);
72
78
 
@@ -75,8 +81,9 @@ export class AuthGuard implements CanActivate {
75
81
  if (isPublic) {
76
82
  return true;
77
83
  } else {
78
- // Return proper 401 status with clear message for JWT errors
79
- const message = (error as any)?.message || (error as any)?.name || 'Unauthorized';
84
+ const errName = (error as any)?.name ?? 'UnknownError';
85
+ const message = (error as any)?.message || errName || 'Unauthorized';
86
+ this.logger.warn(`[AuthGuard] Token rejected ${request.method} ${request.url} error=${errName}: ${message}`);
80
87
  throw new UnauthorizedException(message);
81
88
  }
82
89
  }
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
5
5
  import { SettingModule } from '../setting/setting.module';
6
6
  import { FileController } from './file.controller';
7
7
  import { FileService } from './file.service';
8
+ import { ImageOptimizationService } from './image-optimization.service';
8
9
 
9
10
  @Module({
10
11
  imports: [
@@ -15,8 +16,8 @@ import { FileService } from './file.service';
15
16
  JwtModule,
16
17
  ),
17
18
  ],
18
- providers: [FileService],
19
- exports: [FileService],
19
+ providers: [FileService, ImageOptimizationService],
20
+ exports: [FileService, ImageOptimizationService],
20
21
  controllers: [FileController],
21
22
  })
22
23
  export class FileModule { }
@@ -1,4 +1,17 @@
1
- import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3';
1
+ import {
2
+ GetBucketNotificationConfigurationCommand,
3
+ HeadBucketCommand,
4
+ HeadObjectCommand,
5
+ PutBucketNotificationConfigurationCommand,
6
+ S3Client,
7
+ } from '@aws-sdk/client-s3';
8
+ import {
9
+ AddPermissionCommand,
10
+ CreateFunctionCommand,
11
+ GetFunctionConfigurationCommand,
12
+ LambdaClient,
13
+ UpdateFunctionConfigurationCommand,
14
+ } from '@aws-sdk/client-lambda';
2
15
  import {
3
16
  AssumeRoleCommand,
4
17
  GetSessionTokenCommand,
@@ -8,25 +21,26 @@ import { getLocaleText } from '@hed-hog/api-locale';
8
21
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
9
22
  import { PrismaService } from '@hed-hog/api-prisma';
10
23
  import {
11
- BadRequestException,
12
- forwardRef,
13
- Inject,
14
- Injectable,
15
- Logger,
16
- NotFoundException,
17
- OnModuleInit
24
+ BadRequestException,
25
+ forwardRef,
26
+ Inject,
27
+ Injectable,
28
+ Logger,
29
+ NotFoundException,
30
+ OnModuleInit
18
31
  } from '@nestjs/common';
19
32
  import { JwtService } from '@nestjs/jwt';
20
33
  import { createWriteStream, promises as fs } from 'fs';
21
34
  import { basename, dirname } from 'path';
22
35
  import { pipeline } from 'stream/promises';
23
36
  import {
24
- buildStorageConfigFromIntegration,
25
- resolveFileProviderSlug,
26
- resolveStorageEnumProvider,
37
+ buildStorageConfigFromIntegration,
38
+ resolveFileProviderSlug,
39
+ resolveStorageEnumProvider,
27
40
  } from '../integration-profile/integration-profile.utils';
28
41
  import { SettingService } from '../setting/setting.service';
29
42
  import { DeleteDTO } from './dto/delete.dto';
43
+ import { ImageOptimizationService } from './image-optimization.service';
30
44
  import { AbstractProvider } from './provider/abstract.provider';
31
45
  import { ProviderFactory } from './provider/provider.factory';
32
46
 
@@ -71,6 +85,8 @@ export class FileService implements OnModuleInit {
71
85
  private readonly settingService: SettingService,
72
86
  @Inject(forwardRef(() => JwtService))
73
87
  private readonly jwtService: JwtService,
88
+ @Inject(forwardRef(() => ImageOptimizationService))
89
+ private readonly imageOptimizationService: ImageOptimizationService,
74
90
  ) { }
75
91
 
76
92
  async onModuleInit() {
@@ -397,21 +413,30 @@ export class FileService implements OnModuleInit {
397
413
  fileBuffer.originalname,
398
414
  );
399
415
 
416
+ const optimized = await this.imageOptimizationService.optimizeBuffer(
417
+ fileBuffer.buffer,
418
+ fileBuffer.mimetype,
419
+ normalizedFilename,
420
+ );
421
+
400
422
  const normalizedFile = {
401
423
  ...fileBuffer,
402
- originalname: normalizedFilename,
424
+ buffer: optimized.buffer,
425
+ mimetype: optimized.mimetype,
426
+ originalname: optimized.originalname,
427
+ size: optimized.buffer.length,
403
428
  };
404
429
 
405
430
  const url = await provider.upload(destination, normalizedFile);
406
431
 
407
432
  const file = await this.prismaService.file.create({
408
433
  data: {
409
- filename: normalizedFilename,
434
+ filename: optimized.originalname,
410
435
  path: url,
411
436
  provider_id: this.providerId,
412
437
  location: destination,
413
- mimetype_id: await this.getMimeType(fileBuffer.mimetype),
414
- size: fileBuffer.size,
438
+ mimetype_id: await this.getMimeType(optimized.mimetype),
439
+ size: optimized.buffer.length,
415
440
  },
416
441
  });
417
442
 
@@ -456,45 +481,35 @@ export class FileService implements OnModuleInit {
456
481
  throw new BadRequestException(`File too large: ${size} bytes`);
457
482
  }
458
483
 
459
- const provider = await this.getProvider();
460
- const providerWithPathUpload = provider as AbstractProvider & {
461
- uploadFromPath?: (
462
- destination: string,
463
- filePath: string,
464
- options?: { filename?: string; mimetype?: string },
465
- ) => Promise<string>;
466
- };
484
+ // Read buffer to allow image optimization regardless of provider support
485
+ const rawBuffer = await fs.readFile(filePath);
486
+ const optimized = await this.imageOptimizationService.optimizeBuffer(
487
+ rawBuffer,
488
+ mimetype,
489
+ originalname,
490
+ );
467
491
 
468
- let url: string;
469
- if (typeof providerWithPathUpload.uploadFromPath === 'function') {
470
- url = await providerWithPathUpload.uploadFromPath(destination, filePath, {
471
- filename: originalname,
472
- mimetype,
473
- });
474
- } else {
475
- // Fallback explícito para providers legados sem suporte nativo por path.
476
- const buffer = await fs.readFile(filePath);
477
- url = await provider.upload(destination, {
478
- fieldname: 'file',
479
- originalname,
480
- encoding: metadata?.encoding ?? '7bit',
481
- mimetype,
482
- size,
483
- destination: '',
484
- filename: originalname,
485
- path: filePath,
486
- buffer,
487
- });
488
- }
492
+ const provider = await this.getProvider();
493
+ const url = await provider.upload(destination, {
494
+ fieldname: 'file',
495
+ originalname: optimized.originalname,
496
+ encoding: metadata?.encoding ?? '7bit',
497
+ mimetype: optimized.mimetype,
498
+ size: optimized.buffer.length,
499
+ destination: '',
500
+ filename: optimized.originalname,
501
+ path: filePath,
502
+ buffer: optimized.buffer,
503
+ });
489
504
 
490
505
  const file = await this.prismaService.file.create({
491
506
  data: {
492
- filename: originalname,
507
+ filename: optimized.originalname,
493
508
  path: url,
494
509
  provider_id: this.providerId,
495
510
  location: destination,
496
- mimetype_id: await this.getMimeType(mimetype),
497
- size,
511
+ mimetype_id: await this.getMimeType(optimized.mimetype),
512
+ size: optimized.buffer.length,
498
513
  },
499
514
  });
500
515
 
@@ -754,6 +769,305 @@ export class FileService implements OnModuleInit {
754
769
  };
755
770
  }
756
771
 
772
+ async headS3Bucket(params: {
773
+ accessKeyId: string;
774
+ secretAccessKey: string;
775
+ sessionToken?: string;
776
+ region: string;
777
+ bucket: string;
778
+ }): Promise<void> {
779
+ const s3 = new S3Client({
780
+ region: params.region,
781
+ credentials: {
782
+ accessKeyId: params.accessKeyId,
783
+ secretAccessKey: params.secretAccessKey,
784
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
785
+ },
786
+ });
787
+
788
+ await s3.send(new HeadBucketCommand({ Bucket: params.bucket }));
789
+ }
790
+
791
+ async getLambdaFunctionConfiguration(params: {
792
+ functionName: string;
793
+ region: string;
794
+ accessKeyId: string;
795
+ secretAccessKey: string;
796
+ sessionToken?: string;
797
+ }) {
798
+ const lambda = new LambdaClient({
799
+ region: params.region,
800
+ credentials: {
801
+ accessKeyId: params.accessKeyId,
802
+ secretAccessKey: params.secretAccessKey,
803
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
804
+ },
805
+ });
806
+
807
+ return lambda.send(
808
+ new GetFunctionConfigurationCommand({
809
+ FunctionName: params.functionName,
810
+ }),
811
+ );
812
+ }
813
+
814
+ async updateLambdaEnvironmentVariables(params: {
815
+ functionName: string;
816
+ region: string;
817
+ accessKeyId: string;
818
+ secretAccessKey: string;
819
+ sessionToken?: string;
820
+ environmentVariables: Record<string, string>;
821
+ }) {
822
+ const lambda = new LambdaClient({
823
+ region: params.region,
824
+ credentials: {
825
+ accessKeyId: params.accessKeyId,
826
+ secretAccessKey: params.secretAccessKey,
827
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
828
+ },
829
+ });
830
+
831
+ const current = await lambda.send(
832
+ new GetFunctionConfigurationCommand({
833
+ FunctionName: params.functionName,
834
+ }),
835
+ );
836
+
837
+ const merged = {
838
+ ...(current.Environment?.Variables ?? {}),
839
+ ...params.environmentVariables,
840
+ };
841
+
842
+ await lambda.send(
843
+ new UpdateFunctionConfigurationCommand({
844
+ FunctionName: params.functionName,
845
+ Environment: {
846
+ Variables: merged,
847
+ },
848
+ }),
849
+ );
850
+
851
+ return {
852
+ functionName: current.FunctionName ?? params.functionName,
853
+ functionArn: current.FunctionArn ?? null,
854
+ };
855
+ }
856
+
857
+ async createLambdaFunctionIfMissing(params: {
858
+ functionName: string;
859
+ region: string;
860
+ accessKeyId: string;
861
+ secretAccessKey: string;
862
+ sessionToken?: string;
863
+ roleArn: string;
864
+ packageType: 'Image' | 'Zip';
865
+ imageUri?: string;
866
+ zipFile?: Uint8Array;
867
+ s3Bucket?: string;
868
+ s3Key?: string;
869
+ s3ObjectVersion?: string;
870
+ handler?: string;
871
+ runtime?: string;
872
+ timeout?: number;
873
+ memorySize?: number;
874
+ environmentVariables?: Record<string, string>;
875
+ }) {
876
+ const lambda = new LambdaClient({
877
+ region: params.region,
878
+ credentials: {
879
+ accessKeyId: params.accessKeyId,
880
+ secretAccessKey: params.secretAccessKey,
881
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
882
+ },
883
+ });
884
+
885
+ try {
886
+ const existing = await lambda.send(
887
+ new GetFunctionConfigurationCommand({
888
+ FunctionName: params.functionName,
889
+ }),
890
+ );
891
+
892
+ return {
893
+ created: false,
894
+ functionName: existing.FunctionName ?? params.functionName,
895
+ functionArn: existing.FunctionArn ?? null,
896
+ };
897
+ } catch (error: any) {
898
+ if (String(error?.name ?? '') !== 'ResourceNotFoundException') {
899
+ throw error;
900
+ }
901
+ }
902
+
903
+ const baseInput = {
904
+ FunctionName: params.functionName,
905
+ Role: params.roleArn,
906
+ PackageType: params.packageType,
907
+ Timeout: params.timeout,
908
+ MemorySize: params.memorySize,
909
+ Environment: params.environmentVariables
910
+ ? {
911
+ Variables: params.environmentVariables,
912
+ }
913
+ : undefined,
914
+ } as const;
915
+
916
+ const created =
917
+ params.packageType === 'Image'
918
+ ? await lambda.send(
919
+ new CreateFunctionCommand({
920
+ ...baseInput,
921
+ Code: {
922
+ ImageUri: params.imageUri,
923
+ },
924
+ }),
925
+ )
926
+ : await lambda.send(
927
+ new CreateFunctionCommand({
928
+ ...baseInput,
929
+ Handler: params.handler,
930
+ Runtime: params.runtime as any,
931
+ Code: {
932
+ ...(params.zipFile
933
+ ? {
934
+ ZipFile: params.zipFile,
935
+ }
936
+ : {
937
+ S3Bucket: params.s3Bucket,
938
+ S3Key: params.s3Key,
939
+ ...(params.s3ObjectVersion
940
+ ? {
941
+ S3ObjectVersion: params.s3ObjectVersion,
942
+ }
943
+ : {}),
944
+ }),
945
+ },
946
+ }),
947
+ );
948
+
949
+ return {
950
+ created: true,
951
+ functionName: created.FunctionName ?? params.functionName,
952
+ functionArn: created.FunctionArn ?? null,
953
+ };
954
+ }
955
+
956
+ async allowS3InvokeLambda(params: {
957
+ functionName: string;
958
+ region: string;
959
+ accessKeyId: string;
960
+ secretAccessKey: string;
961
+ sessionToken?: string;
962
+ bucketName: string;
963
+ sourceAccount?: string;
964
+ statementId?: string;
965
+ }) {
966
+ const lambda = new LambdaClient({
967
+ region: params.region,
968
+ credentials: {
969
+ accessKeyId: params.accessKeyId,
970
+ secretAccessKey: params.secretAccessKey,
971
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
972
+ },
973
+ });
974
+
975
+ const statementId =
976
+ params.statementId ?? `lms-bulk-upload-${params.bucketName.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
977
+
978
+ try {
979
+ await lambda.send(
980
+ new AddPermissionCommand({
981
+ FunctionName: params.functionName,
982
+ StatementId: statementId.slice(0, 100),
983
+ Action: 'lambda:InvokeFunction',
984
+ Principal: 's3.amazonaws.com',
985
+ SourceArn: `arn:aws:s3:::${params.bucketName}`,
986
+ ...(params.sourceAccount
987
+ ? {
988
+ SourceAccount: params.sourceAccount,
989
+ }
990
+ : {}),
991
+ }),
992
+ );
993
+ } catch (error: any) {
994
+ if (String(error?.name ?? '') === 'ResourceConflictException') {
995
+ return;
996
+ }
997
+
998
+ throw error;
999
+ }
1000
+ }
1001
+
1002
+ async configureS3BucketLambdaNotification(params: {
1003
+ bucketName: string;
1004
+ lambdaArn: string;
1005
+ region: string;
1006
+ accessKeyId: string;
1007
+ secretAccessKey: string;
1008
+ sessionToken?: string;
1009
+ configurationId?: string;
1010
+ prefix?: string;
1011
+ }) {
1012
+ const s3 = new S3Client({
1013
+ region: params.region,
1014
+ credentials: {
1015
+ accessKeyId: params.accessKeyId,
1016
+ secretAccessKey: params.secretAccessKey,
1017
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
1018
+ },
1019
+ });
1020
+
1021
+ const configurationId =
1022
+ params.configurationId ?? 'lms-bulk-upload-object-created';
1023
+
1024
+ const existing = await s3.send(
1025
+ new GetBucketNotificationConfigurationCommand({
1026
+ Bucket: params.bucketName,
1027
+ }),
1028
+ );
1029
+
1030
+ const lambdaConfigurations = Array.isArray(existing?.LambdaFunctionConfigurations)
1031
+ ? existing.LambdaFunctionConfigurations.filter((cfg: any) => cfg?.Id !== configurationId)
1032
+ : [];
1033
+
1034
+ lambdaConfigurations.push({
1035
+ Id: configurationId,
1036
+ LambdaFunctionArn: params.lambdaArn,
1037
+ Events: ['s3:ObjectCreated:*'],
1038
+ ...(params.prefix
1039
+ ? {
1040
+ Filter: {
1041
+ Key: {
1042
+ FilterRules: [
1043
+ {
1044
+ Name: 'prefix',
1045
+ Value: params.prefix,
1046
+ },
1047
+ ],
1048
+ },
1049
+ },
1050
+ }
1051
+ : {}),
1052
+ });
1053
+
1054
+ await s3.send(
1055
+ new PutBucketNotificationConfigurationCommand({
1056
+ Bucket: params.bucketName,
1057
+ NotificationConfiguration: {
1058
+ TopicConfigurations: Array.isArray(existing?.TopicConfigurations)
1059
+ ? existing.TopicConfigurations
1060
+ : [],
1061
+ QueueConfigurations: Array.isArray(existing?.QueueConfigurations)
1062
+ ? existing.QueueConfigurations
1063
+ : [],
1064
+ EventBridgeConfiguration: existing?.EventBridgeConfiguration,
1065
+ LambdaFunctionConfigurations: lambdaConfigurations,
1066
+ },
1067
+ }),
1068
+ );
1069
+ }
1070
+
757
1071
  createDownloadToken(filepath: string, expires = 3600) {
758
1072
  if (!filepath) {
759
1073
  throw new BadRequestException(`No file path provided`);
@@ -0,0 +1,123 @@
1
+ import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
2
+ import { basename, extname } from 'path';
3
+ import sharp from 'sharp';
4
+ import { SettingService } from '../setting/setting.service';
5
+
6
+ export interface OptimizedImage {
7
+ buffer: Buffer;
8
+ mimetype: string;
9
+ originalname: string;
10
+ }
11
+
12
+ @Injectable()
13
+ export class ImageOptimizationService {
14
+ private readonly logger = new Logger(ImageOptimizationService.name);
15
+
16
+ constructor(
17
+ @Inject(forwardRef(() => SettingService))
18
+ private readonly settingService: SettingService,
19
+ ) {}
20
+
21
+ private parseArraySetting(raw: string | undefined): string[] {
22
+ if (!raw) return [];
23
+ try {
24
+ const parsed = JSON.parse(raw);
25
+ return Array.isArray(parsed) ? parsed : [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ private replaceExtension(filename: string, newExt: string): string {
32
+ const base = basename(filename, extname(filename));
33
+ return `${base}.${newExt}`;
34
+ }
35
+
36
+ /**
37
+ * Applies image compression and/or WebP conversion to the given buffer
38
+ * based on the current settings. Returns the original data unchanged if
39
+ * the settings are disabled or the MIME type is not in the configured lists.
40
+ */
41
+ async optimizeBuffer(
42
+ buffer: Buffer,
43
+ mimetype: string,
44
+ originalname: string,
45
+ ): Promise<OptimizedImage> {
46
+ if (!mimetype.startsWith('image/')) {
47
+ return { buffer, mimetype, originalname };
48
+ }
49
+
50
+ const settings = await this.settingService.getSettingValues([
51
+ 'image-compression-enabled',
52
+ 'image-compression-quality',
53
+ 'image-compression-types',
54
+ 'image-webp-conversion-enabled',
55
+ 'image-webp-convert-types',
56
+ ]);
57
+
58
+ const compressionEnabled = settings['image-compression-enabled'] === 'true';
59
+ const webpEnabled = settings['image-webp-conversion-enabled'] === 'true';
60
+
61
+ if (!compressionEnabled && !webpEnabled) {
62
+ return { buffer, mimetype, originalname };
63
+ }
64
+
65
+ const quality = Math.min(
66
+ 100,
67
+ Math.max(1, Number(settings['image-compression-quality']) || 80),
68
+ );
69
+ const compressionTypes = this.parseArraySetting(settings['image-compression-types']);
70
+ const webpConvertTypes = this.parseArraySetting(settings['image-webp-convert-types']);
71
+
72
+ // WebP conversion has priority over plain compression
73
+ if (webpEnabled && webpConvertTypes.includes(mimetype)) {
74
+ try {
75
+ const optimized = await sharp(buffer).webp({ quality }).toBuffer();
76
+ return {
77
+ buffer: optimized,
78
+ mimetype: 'image/webp',
79
+ originalname: this.replaceExtension(originalname, 'webp'),
80
+ };
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
+ }
87
+ }
88
+
89
+ if (compressionEnabled && compressionTypes.includes(mimetype)) {
90
+ try {
91
+ let instance = sharp(buffer);
92
+ if (mimetype === 'image/jpeg') {
93
+ instance = instance.jpeg({ quality });
94
+ } else if (mimetype === 'image/png') {
95
+ // sharp compressionLevel is 0-9 (9 = max); invert from quality scale
96
+ const compressionLevel = Math.round(((100 - quality) / 100) * 9);
97
+ instance = instance.png({ compressionLevel });
98
+ } else if (mimetype === 'image/webp') {
99
+ instance = instance.webp({ quality });
100
+ } else if (mimetype === 'image/bmp' || mimetype === 'image/tiff') {
101
+ // Convert to PNG as a compressed lossless alternative
102
+ instance = instance.png({ compressionLevel: Math.round(((100 - quality) / 100) * 9) });
103
+ return {
104
+ buffer: await instance.toBuffer(),
105
+ mimetype: 'image/png',
106
+ originalname: this.replaceExtension(originalname, 'png'),
107
+ };
108
+ } else {
109
+ return { buffer, mimetype, originalname };
110
+ }
111
+ const optimized = await instance.toBuffer();
112
+ 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
+ }
119
+ }
120
+
121
+ return { buffer, mimetype, originalname };
122
+ }
123
+ }
@@ -27,15 +27,8 @@ export class LocalProvider extends AbstractProvider {
27
27
  }
28
28
  }
29
29
 
30
- async createFolderRecursive(path: string) {
31
- const folders = join(path).split(sep);
32
- let currentPath = '';
33
- for (const folder of folders) {
34
- currentPath = join(currentPath, folder);
35
- if (!existsSync(currentPath)) {
36
- await mkdir(currentPath, { recursive: true });
37
- }
38
- }
30
+ async createFolderRecursive(folderPath: string): Promise<void> {
31
+ await mkdir(folderPath, { recursive: true });
39
32
  }
40
33
 
41
34
  private resolveFilepath(filepath: string): string {
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export * from './ai/ai.service';
39
39
 
40
40
  export * from './webhook-integration/event-webhook.service';
41
41
  export * from './webhook-integration/integration-event-catalog.service';
42
+ export * from './webhook-integration/webhook-command.registry';
42
43
  export * from './webhook-integration/webhook-integration.module';
43
44
  export * from './webhook-integration/webhook-integration.service';
44
45