@hed-hog/core 0.0.370 → 0.0.373

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 (248) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +2 -0
  3. package/dist/ai/ai.service.d.ts.map +1 -1
  4. package/dist/ai/ai.service.js +10 -7
  5. package/dist/ai/ai.service.js.map +1 -1
  6. package/dist/app-command/app-command.module.d.ts +3 -0
  7. package/dist/app-command/app-command.module.d.ts.map +1 -0
  8. package/dist/app-command/app-command.module.js +22 -0
  9. package/dist/app-command/app-command.module.js.map +1 -0
  10. package/dist/app-command/app-command.registry.d.ts +22 -0
  11. package/dist/app-command/app-command.registry.d.ts.map +1 -0
  12. package/dist/{webhook-integration/webhook-command.registry.js → app-command/app-command.registry.js} +6 -6
  13. package/dist/app-command/app-command.registry.js.map +1 -0
  14. package/dist/auth/auth.controller.d.ts +2 -2
  15. package/dist/auth/auth.controller.d.ts.map +1 -1
  16. package/dist/auth/auth.controller.js +20 -7
  17. package/dist/auth/auth.controller.js.map +1 -1
  18. package/dist/auth/auth.service.d.ts +3 -3
  19. package/dist/auth/auth.service.d.ts.map +1 -1
  20. package/dist/auth/auth.service.js +10 -36
  21. package/dist/auth/auth.service.js.map +1 -1
  22. package/dist/auth/dto/forget.dto.d.ts +1 -0
  23. package/dist/auth/dto/forget.dto.d.ts.map +1 -1
  24. package/dist/auth/dto/forget.dto.js +5 -0
  25. package/dist/auth/dto/forget.dto.js.map +1 -1
  26. package/dist/auth/guards/auth.guard.d.ts.map +1 -1
  27. package/dist/auth/guards/auth.guard.js +4 -1
  28. package/dist/auth/guards/auth.guard.js.map +1 -1
  29. package/dist/challenge/challenge.service.d.ts +8 -7
  30. package/dist/challenge/challenge.service.d.ts.map +1 -1
  31. package/dist/challenge/challenge.service.js +27 -19
  32. package/dist/challenge/challenge.service.js.map +1 -1
  33. package/dist/file/dto/upload.dto.d.ts +3 -0
  34. package/dist/file/dto/upload.dto.d.ts.map +1 -1
  35. package/dist/file/dto/upload.dto.js +22 -0
  36. package/dist/file/dto/upload.dto.js.map +1 -1
  37. package/dist/file/file.controller.d.ts +2 -1
  38. package/dist/file/file.controller.d.ts.map +1 -1
  39. package/dist/file/file.controller.js +29 -16
  40. package/dist/file/file.controller.js.map +1 -1
  41. package/dist/file/file.service.d.ts +51 -1
  42. package/dist/file/file.service.d.ts.map +1 -1
  43. package/dist/file/file.service.js +250 -18
  44. package/dist/file/file.service.js.map +1 -1
  45. package/dist/file/image-optimization.service.d.ts +17 -4
  46. package/dist/file/image-optimization.service.d.ts.map +1 -1
  47. package/dist/file/image-optimization.service.js +101 -46
  48. package/dist/file/image-optimization.service.js.map +1 -1
  49. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  50. package/dist/file/provider/s3.provider.js +15 -7
  51. package/dist/file/provider/s3.provider.js.map +1 -1
  52. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  53. package/dist/file/provider/spaces.provider.js +18 -8
  54. package/dist/file/provider/spaces.provider.js.map +1 -1
  55. package/dist/index.d.ts +4 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +4 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/integration/services/event-subscriber.registry.d.ts +5 -0
  60. package/dist/integration/services/event-subscriber.registry.d.ts.map +1 -1
  61. package/dist/integration/services/event-subscriber.registry.js +15 -0
  62. package/dist/integration/services/event-subscriber.registry.js.map +1 -1
  63. package/dist/integration/services/outbox.processor.d.ts.map +1 -1
  64. package/dist/integration/services/outbox.processor.js +2 -4
  65. package/dist/integration/services/outbox.processor.js.map +1 -1
  66. package/dist/integration/services/outbox.service.d.ts +4 -2
  67. package/dist/integration/services/outbox.service.d.ts.map +1 -1
  68. package/dist/integration/services/outbox.service.js +26 -15
  69. package/dist/integration/services/outbox.service.js.map +1 -1
  70. package/dist/integration-profile/integration-credential-crypto.service.d.ts +43 -0
  71. package/dist/integration-profile/integration-credential-crypto.service.d.ts.map +1 -0
  72. package/dist/integration-profile/integration-credential-crypto.service.js +142 -0
  73. package/dist/integration-profile/integration-credential-crypto.service.js.map +1 -0
  74. package/dist/integration-profile/integration-profile.controller.d.ts +6 -7
  75. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  76. package/dist/integration-profile/integration-profile.controller.js +4 -3
  77. package/dist/integration-profile/integration-profile.controller.js.map +1 -1
  78. package/dist/integration-profile/integration-profile.module.d.ts.map +1 -1
  79. package/dist/integration-profile/integration-profile.module.js +5 -2
  80. package/dist/integration-profile/integration-profile.module.js.map +1 -1
  81. package/dist/integration-profile/integration-profile.secrets.d.ts +16 -0
  82. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -0
  83. package/dist/integration-profile/integration-profile.secrets.js +56 -0
  84. package/dist/integration-profile/integration-profile.secrets.js.map +1 -0
  85. package/dist/integration-profile/integration-profile.service.d.ts +125 -8
  86. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  87. package/dist/integration-profile/integration-profile.service.js +257 -68
  88. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  89. package/dist/integration-profile/integration-profile.utils.d.ts +17 -0
  90. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  91. package/dist/integration-profile/integration-profile.utils.js +31 -0
  92. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  93. package/dist/language/en.json +178 -0
  94. package/dist/language/pt.json +178 -0
  95. package/dist/mail/mail.controller.d.ts.map +1 -1
  96. package/dist/mail/mail.controller.js +5 -28
  97. package/dist/mail/mail.controller.js.map +1 -1
  98. package/dist/mail/mail.module.js +18 -18
  99. package/dist/mail/mail.module.js.map +1 -1
  100. package/dist/mail/mail.service.d.ts +13 -2
  101. package/dist/mail/mail.service.d.ts.map +1 -1
  102. package/dist/mail/mail.service.js +25 -15
  103. package/dist/mail/mail.service.js.map +1 -1
  104. package/dist/mcp-chat/mcp-chat.service.js +6 -5
  105. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  106. package/dist/oauth/providers/facebook.provider.js +5 -5
  107. package/dist/oauth/providers/facebook.provider.js.map +1 -1
  108. package/dist/oauth/providers/github.provider.js +5 -5
  109. package/dist/oauth/providers/github.provider.js.map +1 -1
  110. package/dist/oauth/providers/google.provider.js +5 -5
  111. package/dist/oauth/providers/google.provider.js.map +1 -1
  112. package/dist/oauth/providers/microsoft-entra-id.provider.js +5 -5
  113. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  114. package/dist/oauth/providers/microsoft.provider.js +5 -5
  115. package/dist/oauth/providers/microsoft.provider.js.map +1 -1
  116. package/dist/pdf/pdf.service.d.ts +4 -0
  117. package/dist/pdf/pdf.service.d.ts.map +1 -1
  118. package/dist/pdf/pdf.service.js +16 -9
  119. package/dist/pdf/pdf.service.js.map +1 -1
  120. package/dist/profile/profile.controller.d.ts +7 -7
  121. package/dist/profile/profile.controller.d.ts.map +1 -1
  122. package/dist/profile/profile.controller.js +28 -21
  123. package/dist/profile/profile.controller.js.map +1 -1
  124. package/dist/profile/profile.service.d.ts +7 -7
  125. package/dist/profile/profile.service.d.ts.map +1 -1
  126. package/dist/profile/profile.service.js +21 -25
  127. package/dist/profile/profile.service.js.map +1 -1
  128. package/dist/setting/setting.service.d.ts +1 -0
  129. package/dist/setting/setting.service.d.ts.map +1 -1
  130. package/dist/setting/setting.service.js +15 -2
  131. package/dist/setting/setting.service.js.map +1 -1
  132. package/dist/token/token.service.d.ts.map +1 -1
  133. package/dist/token/token.service.js +2 -1
  134. package/dist/token/token.service.js.map +1 -1
  135. package/dist/user/user.controller.d.ts +9 -0
  136. package/dist/user/user.controller.d.ts.map +1 -1
  137. package/dist/user/user.controller.js +13 -0
  138. package/dist/user/user.controller.js.map +1 -1
  139. package/dist/user/user.service.d.ts +7 -0
  140. package/dist/user/user.service.d.ts.map +1 -1
  141. package/dist/user/user.service.js +19 -0
  142. package/dist/user/user.service.js.map +1 -1
  143. package/dist/utils/webauthn-origin.d.ts +11 -0
  144. package/dist/utils/webauthn-origin.d.ts.map +1 -0
  145. package/dist/utils/webauthn-origin.js +37 -0
  146. package/dist/utils/webauthn-origin.js.map +1 -0
  147. package/dist/webhook-integration/webhook-integration.controller.d.ts +13 -0
  148. package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -1
  149. package/dist/webhook-integration/webhook-integration.controller.js +29 -0
  150. package/dist/webhook-integration/webhook-integration.controller.js.map +1 -1
  151. package/dist/webhook-integration/webhook-integration.module.js +3 -3
  152. package/dist/webhook-integration/webhook-integration.module.js.map +1 -1
  153. package/dist/webhook-integration/webhook-integration.service.d.ts +18 -2
  154. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  155. package/dist/webhook-integration/webhook-integration.service.js +110 -6
  156. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  157. package/hedhog/data/integration_provider.yaml +9 -0
  158. package/hedhog/data/integration_type.yaml +6 -0
  159. package/hedhog/data/menu.yaml +6 -0
  160. package/hedhog/data/role.yaml +7 -0
  161. package/hedhog/data/route.yaml +36 -0
  162. package/hedhog/data/setting_group.yaml +119 -13
  163. package/hedhog/frontend/app/access-log/page.tsx.ejs +35 -3
  164. package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +36 -3
  165. package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +39 -4
  166. package/hedhog/frontend/app/account/components/tokens.tsx.ejs +39 -4
  167. package/hedhog/frontend/app/ai_agent/page.tsx.ejs +35 -2
  168. package/hedhog/frontend/app/configurations/[slug]/components/byte-size-input.tsx.ejs +160 -0
  169. package/hedhog/frontend/app/configurations/[slug]/components/duration-input.tsx.ejs +157 -0
  170. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +244 -63
  171. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +33 -1
  172. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +35 -3
  173. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +36 -4
  174. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +35 -3
  175. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +36 -4
  176. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +35 -3
  177. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +156 -38
  178. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +118 -11
  179. package/hedhog/frontend/app/mail/log/page.tsx.ejs +37 -4
  180. package/hedhog/frontend/app/menu/page.tsx.ejs +34 -2
  181. package/hedhog/frontend/app/preferences/page.tsx.ejs +77 -0
  182. package/hedhog/frontend/app/roles/menus.tsx.ejs +35 -3
  183. package/hedhog/frontend/app/roles/page.tsx.ejs +34 -2
  184. package/hedhog/frontend/app/roles/routes.tsx.ejs +36 -4
  185. package/hedhog/frontend/app/roles/users.tsx.ejs +36 -4
  186. package/hedhog/frontend/app/routes/page.tsx.ejs +34 -2
  187. package/hedhog/frontend/app/setting-list/page.tsx.ejs +34 -2
  188. package/hedhog/frontend/app/users/page.tsx.ejs +51 -21
  189. package/hedhog/frontend/messages/en.json +69 -4
  190. package/hedhog/frontend/messages/pt.json +69 -4
  191. package/hedhog/table/setting.yaml +2 -0
  192. package/hedhog/table/webhook_call_log.yaml +49 -46
  193. package/package.json +9 -4
  194. package/src/ai/ai.service.ts +9 -6
  195. package/src/app-command/app-command.module.ts +9 -0
  196. package/src/{webhook-integration/webhook-command.registry.ts → app-command/app-command.registry.ts} +9 -9
  197. package/src/auth/auth.controller.ts +20 -4
  198. package/src/auth/auth.service.ts +10 -36
  199. package/src/auth/dto/forget.dto.ts +5 -1
  200. package/src/auth/guards/auth.guard.ts +4 -1
  201. package/src/challenge/challenge.service.ts +30 -19
  202. package/src/file/dto/upload.dto.ts +25 -1
  203. package/src/file/file.controller.ts +31 -18
  204. package/src/file/file.service.ts +341 -16
  205. package/src/file/image-optimization.service.ts +111 -46
  206. package/src/file/provider/s3.provider.ts +16 -7
  207. package/src/file/provider/spaces.provider.ts +15 -5
  208. package/src/index.ts +4 -1
  209. package/src/integration/services/event-subscriber.registry.ts +15 -0
  210. package/src/integration/services/outbox.processor.ts +2 -5
  211. package/src/integration/services/outbox.service.ts +35 -17
  212. package/src/integration-profile/integration-credential-crypto.service.ts +146 -0
  213. package/src/integration-profile/integration-profile.controller.ts +2 -0
  214. package/src/integration-profile/integration-profile.module.ts +5 -2
  215. package/src/integration-profile/integration-profile.secrets.ts +51 -0
  216. package/src/integration-profile/integration-profile.service.ts +335 -54
  217. package/src/integration-profile/integration-profile.utils.ts +56 -0
  218. package/src/language/en.json +5 -1
  219. package/src/language/pt.json +5 -1
  220. package/src/mail/mail.controller.ts +5 -29
  221. package/src/mail/mail.module.ts +5 -5
  222. package/src/mail/mail.service.ts +37 -13
  223. package/src/mcp-chat/mcp-chat.service.ts +5 -5
  224. package/src/oauth/oauth-callback-coordinator.service.spec.ts +159 -0
  225. package/src/oauth/providers/facebook.provider.ts +3 -3
  226. package/src/oauth/providers/github.provider.ts +3 -3
  227. package/src/oauth/providers/google.provider.ts +3 -3
  228. package/src/oauth/providers/microsoft-entra-id.provider.ts +3 -3
  229. package/src/oauth/providers/microsoft.provider.ts +3 -3
  230. package/src/pdf/pdf.service.ts +19 -6
  231. package/src/profile/profile.controller.ts +21 -14
  232. package/src/profile/profile.service.ts +23 -20
  233. package/src/security/security.service.spec.ts +222 -0
  234. package/src/setting/setting.service.ts +15 -2
  235. package/src/token/token.service.ts +2 -1
  236. package/src/user/user.controller.ts +26 -16
  237. package/src/user/user.service.ts +25 -0
  238. package/src/utils/webauthn-origin.ts +38 -0
  239. package/src/webhook-integration/webhook-integration.controller.ts +23 -0
  240. package/src/webhook-integration/webhook-integration.module.ts +3 -3
  241. package/src/webhook-integration/webhook-integration.service.ts +115 -5
  242. package/dist/webhook-integration/webhook-command.registry.d.ts +0 -22
  243. package/dist/webhook-integration/webhook-command.registry.d.ts.map +0 -1
  244. package/dist/webhook-integration/webhook-command.registry.js.map +0 -1
  245. package/src/menu/menu.service.spec.ts +0 -247
  246. package/src/route/route.service.spec.ts +0 -300
  247. package/src/screen/screen.service.spec.ts +0 -298
  248. package/src/user/user.service.spec.ts +0 -294
@@ -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 rawProfileId = settings['file-storage-profile-id'];
108
- const profileId = Number(rawProfileId);
107
+ const profileSlug = String(settings['file-storage-profile-id'] ?? '').trim();
109
108
 
110
- if (!Number.isFinite(profileId) || profileId <= 0) {
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: { id: profileId },
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 id ${profileId} not found.`);
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 id ${profileId} is not a storage profile.`,
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(mimetype))) {
218
- throw new BadRequestException(`Invalid file type: ${mimetype}`);
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
- mimetype,
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(mimetype),
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(destination: string, fileBuffer: MulterFile) {
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
- fileBuffer.mimetype,
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 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.
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
- // WebP conversion has priority over plain compression
73
- if (webpEnabled && webpConvertTypes.includes(mimetype)) {
74
- try {
75
- const optimized = await sharp(buffer).webp({ quality }).toBuffer();
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
- 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
- }
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
  }