@hed-hog/core 0.0.401 → 0.0.402

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 (106) hide show
  1. package/dist/access-log/access-log.interceptor.d.ts +10 -0
  2. package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
  3. package/dist/access-log/access-log.interceptor.js +15 -1
  4. package/dist/access-log/access-log.interceptor.js.map +1 -1
  5. package/dist/file/file-upload-ticket.service.d.ts +1 -1
  6. package/dist/file/file.controller.d.ts +3 -3
  7. package/dist/file/file.service.d.ts +121 -12
  8. package/dist/file/file.service.d.ts.map +1 -1
  9. package/dist/file/file.service.js +245 -3
  10. package/dist/file/file.service.js.map +1 -1
  11. package/dist/file/provider/abstract.provider.d.ts +33 -0
  12. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  13. package/dist/file/provider/abstract.provider.js +9 -0
  14. package/dist/file/provider/abstract.provider.js.map +1 -1
  15. package/dist/file/provider/s3-multipart.util.d.ts +17 -0
  16. package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
  17. package/dist/file/provider/s3-multipart.util.js +88 -0
  18. package/dist/file/provider/s3-multipart.util.js.map +1 -0
  19. package/dist/file/provider/s3.provider.d.ts +9 -1
  20. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  21. package/dist/file/provider/s3.provider.js +16 -0
  22. package/dist/file/provider/s3.provider.js.map +1 -1
  23. package/dist/file/provider/spaces.provider.d.ts +10 -1
  24. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  25. package/dist/file/provider/spaces.provider.js +17 -0
  26. package/dist/file/provider/spaces.provider.js.map +1 -1
  27. package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
  28. package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
  29. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  30. package/dist/integration-profile/integration-profile.secrets.js +7 -2
  31. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  32. package/dist/integration-profile/integration-profile.service.d.ts +1 -0
  33. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  34. package/dist/integration-profile/integration-profile.service.js +3 -3
  35. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  36. package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
  37. package/dist/integration-profile/testers/http-probe.js +5 -3
  38. package/dist/integration-profile/testers/http-probe.js.map +1 -1
  39. package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
  40. package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
  41. package/dist/mail/mail.service.d.ts.map +1 -1
  42. package/dist/mail/mail.service.js +16 -1
  43. package/dist/mail/mail.service.js.map +1 -1
  44. package/dist/mcp/mcp-server.service.d.ts.map +1 -1
  45. package/dist/mcp/mcp-server.service.js +10 -1
  46. package/dist/mcp/mcp-server.service.js.map +1 -1
  47. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -1
  48. package/dist/pdf/document/pdf-document.css.js +88 -0
  49. package/dist/pdf/document/pdf-document.css.js.map +1 -1
  50. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -1
  51. package/dist/pdf/document/pdf-document.renderer.js +73 -0
  52. package/dist/pdf/document/pdf-document.renderer.js.map +1 -1
  53. package/dist/pdf/document/pdf-document.types.d.ts +56 -0
  54. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -1
  55. package/dist/pdf/pdf.service.d.ts +6 -0
  56. package/dist/pdf/pdf.service.d.ts.map +1 -1
  57. package/dist/pdf/pdf.service.js +3 -0
  58. package/dist/pdf/pdf.service.js.map +1 -1
  59. package/dist/setting/setting.service.d.ts.map +1 -1
  60. package/dist/setting/setting.service.js +3 -0
  61. package/dist/setting/setting.service.js.map +1 -1
  62. package/dist/task/task.service.d.ts +6 -0
  63. package/dist/task/task.service.d.ts.map +1 -1
  64. package/dist/task/task.service.js +44 -13
  65. package/dist/task/task.service.js.map +1 -1
  66. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
  67. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  68. package/dist/whatsapp-sender/whatsapp-sender.service.js +83 -21
  69. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  70. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
  71. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
  72. package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
  73. package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
  74. package/hedhog/frontend/messages/en.json +46 -20
  75. package/hedhog/frontend/messages/pt.json +46 -20
  76. package/hedhog/table/file.yaml +3 -1
  77. package/package.json +4 -4
  78. package/src/access-log/access-log.interceptor.spec.ts +50 -0
  79. package/src/access-log/access-log.interceptor.ts +18 -0
  80. package/src/file/file-direct-upload.spec.ts +344 -0
  81. package/src/file/file.service.ts +363 -2
  82. package/src/file/provider/abstract.provider.ts +57 -0
  83. package/src/file/provider/s3-multipart.spec.ts +123 -0
  84. package/src/file/provider/s3-multipart.util.ts +126 -0
  85. package/src/file/provider/s3.provider.ts +65 -1
  86. package/src/file/provider/spaces.provider.ts +67 -1
  87. package/src/integration-profile/integration-profile.secrets.ts +7 -2
  88. package/src/integration-profile/integration-profile.service.ts +1 -0
  89. package/src/integration-profile/testers/http-probe.ts +5 -3
  90. package/src/integration-profile/testers/integration-tester.types.ts +5 -0
  91. package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
  92. package/src/integration-profile/testers/tester-coverage.spec.ts +2 -0
  93. package/src/mail/mail.service.spec.ts +25 -0
  94. package/src/mail/mail.service.ts +18 -1
  95. package/src/mcp/mcp-server.service.spec.ts +16 -0
  96. package/src/mcp/mcp-server.service.ts +10 -1
  97. package/src/pdf/document/pdf-document.css.ts +88 -0
  98. package/src/pdf/document/pdf-document.renderer.spec.ts +43 -0
  99. package/src/pdf/document/pdf-document.renderer.ts +102 -0
  100. package/src/pdf/document/pdf-document.types.ts +52 -0
  101. package/src/pdf/pdf.service.ts +11 -0
  102. package/src/setting/setting.service.ts +3 -0
  103. package/src/task/task.service.spec.ts +77 -1
  104. package/src/task/task.service.ts +58 -14
  105. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +191 -1
  106. package/src/whatsapp-sender/whatsapp-sender.service.ts +122 -14
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DeleteObjectCommand,
2
3
  GetBucketNotificationConfigurationCommand,
3
4
  GetObjectCommand,
4
5
  HeadBucketCommand,
@@ -25,6 +26,7 @@ import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
25
26
  import { PrismaService } from '@hed-hog/api-prisma';
26
27
  import {
27
28
  BadRequestException,
29
+ ConflictException,
28
30
  ForbiddenException,
29
31
  forwardRef,
30
32
  Inject,
@@ -57,6 +59,8 @@ import {
57
59
  } from './image-optimization.service';
58
60
  import {
59
61
  AbstractProvider,
62
+ MultipartUploadPart,
63
+ supportsDirectUpload,
60
64
  supportsServerSideCopy,
61
65
  } from './provider/abstract.provider';
62
66
  import { ProviderFactory } from './provider/provider.factory';
@@ -82,6 +86,49 @@ const PROVIDER_CACHE_TTL_MS = Math.max(
82
86
  Number(process.env.FILE_PROVIDER_CACHE_TTL_MS ?? 30_000),
83
87
  );
84
88
 
89
+ /** Upload direto em partes: mínimo de 16 MiB por parte e até 9000 partes (regra do Studio). */
90
+ export const DIRECT_UPLOAD_MIN_PART_BYTES = 16 * 1024 * 1024;
91
+ export const DIRECT_UPLOAD_MAX_PARTS = 9000;
92
+ /** Validade do token e das URLs de parte: um vídeo grande em conexão lenta leva horas. */
93
+ export const DIRECT_UPLOAD_DEFAULT_TTL_SECONDS = 86_400;
94
+ const DIRECT_UPLOAD_TOKEN_TYPE = 'file-direct-upload';
95
+ /**
96
+ * `file.size` é BIGINT no banco, e o PrismaService o devolve como number: o teto real é o
97
+ * maior inteiro exato de um number (~9 PB).
98
+ */
99
+ const FILE_SIZE_COLUMN_MAX = Number.MAX_SAFE_INTEGER;
100
+
101
+ export type DirectUploadFileInfo = {
102
+ originalname: string;
103
+ mimetype: string;
104
+ size: number;
105
+ isPrivate?: boolean;
106
+ createdByUserId?: number | null;
107
+ /** Mesmo sentido do `uploadFromPath`: o chamador já conferiu o tipo pelas regras dele. */
108
+ skipMimeValidation?: boolean;
109
+ /**
110
+ * O chamador já conferiu o tamanho pelas regras dele: pula a `storage-max-size`. O teto
111
+ * da coluna `file.size` continua valendo.
112
+ */
113
+ skipSizeValidation?: boolean;
114
+ };
115
+
116
+ export type CompleteDirectUploadInput = DirectUploadFileInfo & {
117
+ key: string;
118
+ uploadId: string;
119
+ destination: string;
120
+ parts: MultipartUploadPart[];
121
+ /**
122
+ * Confere os primeiros `bytes` do objeto gravado (ex.: sniff do tipo real). Se `check`
123
+ * lançar, o objeto é apagado e o erro sobe como veio; se devolver um mimetype, é ele
124
+ * que vai para a linha `file` (o tipo real, no lugar do declarado).
125
+ */
126
+ verifyHead?: {
127
+ bytes: number;
128
+ check: (head: Buffer) => string | void | Promise<string | void>;
129
+ };
130
+ };
131
+
85
132
  /** Raw `?w=`/`?f=`/`?q=` query values for the public image endpoint. */
86
133
  export type ImageVariantRequest = {
87
134
  width?: number | string;
@@ -398,7 +445,7 @@ export class FileService implements OnModuleInit {
398
445
 
399
446
  const provider = await this.getProvider();
400
447
  const providerPath = this.resolveProviderPath(file);
401
- const totalSize: number = file.size ?? 0;
448
+ const totalSize = Number(file.size ?? 0);
402
449
 
403
450
  const resolvedStart = start ?? 0;
404
451
  const resolvedEnd = end !== undefined ? end : totalSize > 0 ? totalSize - 1 : undefined;
@@ -783,6 +830,292 @@ export class FileService implements OnModuleInit {
783
830
  return file;
784
831
  }
785
832
 
833
+ /**
834
+ * O storage ativo aceita upload direto em partes assinadas (S3 e Spaces)? Sem provider
835
+ * configurado, ou com um que não implementa, é `false` — quem oferece o caminho cai no
836
+ * upload pela API.
837
+ */
838
+ async supportsDirectUpload(): Promise<boolean> {
839
+ try {
840
+ return supportsDirectUpload(await this.getProvider());
841
+ } catch {
842
+ return false;
843
+ }
844
+ }
845
+
846
+ private async getDirectUploadProvider() {
847
+ const provider = await this.getProvider();
848
+ if (!supportsDirectUpload(provider)) {
849
+ throw new ConflictException('The file storage does not support direct uploads');
850
+ }
851
+ return provider;
852
+ }
853
+
854
+ /** `key` gravada pelo start: `${destination}/${uuid.ext}`, sem subpasta extra. */
855
+ private assertDirectUploadKey(key: string, destination: string): string {
856
+ const prefix = `${destination}/`;
857
+ const storedFilename = typeof key === 'string' && key.startsWith(prefix)
858
+ ? key.slice(prefix.length)
859
+ : '';
860
+
861
+ if (!storedFilename || storedFilename.includes('/')) {
862
+ throw new BadRequestException('Invalid direct upload key');
863
+ }
864
+
865
+ return storedFilename;
866
+ }
867
+
868
+ /**
869
+ * Abre um upload multipart direto no storage. A chave segue o mesmo formato que o
870
+ * `uploadFromPath` grava (`file.location` = destination, `file.path` = uuid.ext do
871
+ * `getFilename` do provider), então o arquivo final é servido como qualquer outro.
872
+ *
873
+ * Nada vai para o banco aqui: o estado entre as chamadas fica com quem chamou (token
874
+ * assinado, ver `signDirectUploadToken`) e a linha `file` só nasce no complete.
875
+ * Tamanho da parte com a regra do Studio: no mínimo 16 MiB e no máximo 9000 partes.
876
+ */
877
+ async startDirectUpload(destination: string, info: DirectUploadFileInfo) {
878
+ if (!destination) {
879
+ throw new BadRequestException(`No destination provided`);
880
+ }
881
+
882
+ const size = Number(info?.size);
883
+ if (!Number.isInteger(size) || size <= 0) {
884
+ throw new BadRequestException(`No size provided`);
885
+ }
886
+
887
+ // Acima disso o tamanho não cabe exato num number, e a linha não teria como ser lida.
888
+ if (size > FILE_SIZE_COLUMN_MAX) {
889
+ throw new BadRequestException(`File too large: ${size} bytes`);
890
+ }
891
+
892
+ const mimetype = String(info?.mimetype ?? '').trim();
893
+ if (!info?.skipMimeValidation && !(await this.acceptMimetypes(mimetype))) {
894
+ throw new BadRequestException(`Invalid file type: ${mimetype}`);
895
+ }
896
+
897
+ if (!info?.skipSizeValidation && !(await this.maxFileSize(size))) {
898
+ throw new BadRequestException(`File too large: ${size} bytes`);
899
+ }
900
+
901
+ const originalname = this.normalizeUploadedFilename(
902
+ String(info?.originalname ?? '').trim() || 'file',
903
+ );
904
+ const provider = await this.getDirectUploadProvider();
905
+ const key = `${destination}/${provider.getFilename(originalname)}`;
906
+ const uploadId = await provider.createMultipartUpload(key, mimetype || 'application/octet-stream');
907
+
908
+ const partSize = Math.max(
909
+ DIRECT_UPLOAD_MIN_PART_BYTES,
910
+ Math.ceil(size / DIRECT_UPLOAD_MAX_PARTS),
911
+ );
912
+
913
+ return {
914
+ key,
915
+ uploadId,
916
+ partSize,
917
+ partCount: Math.max(1, Math.ceil(size / partSize)),
918
+ };
919
+ }
920
+
921
+ /** URLs assinadas de PUT para as partes pedidas (1-based, sem repetir). */
922
+ async signDirectUploadParts(
923
+ key: string,
924
+ uploadId: string,
925
+ partNumbers: number[],
926
+ ttlSeconds = DIRECT_UPLOAD_DEFAULT_TTL_SECONDS,
927
+ ) {
928
+ if (!key || !uploadId) {
929
+ throw new BadRequestException('Invalid direct upload');
930
+ }
931
+
932
+ const numbers = Array.isArray(partNumbers) ? partNumbers.map(Number) : [];
933
+ if (
934
+ numbers.length === 0 ||
935
+ numbers.some((n) => !Number.isInteger(n) || n < 1 || n > DIRECT_UPLOAD_MAX_PARTS)
936
+ ) {
937
+ throw new BadRequestException('Invalid part numbers');
938
+ }
939
+
940
+ const provider = await this.getDirectUploadProvider();
941
+ return provider.signUploadParts(
942
+ key,
943
+ uploadId,
944
+ [...new Set(numbers)],
945
+ Math.max(60, Math.floor(ttlSeconds)),
946
+ );
947
+ }
948
+
949
+ /** Aborta o multipart (as partes já enviadas somem do bucket). Upload já fechado não é erro. */
950
+ async abortDirectUpload(key: string, uploadId: string): Promise<void> {
951
+ if (!key || !uploadId) {
952
+ throw new BadRequestException('Invalid direct upload');
953
+ }
954
+
955
+ const provider = await this.getDirectUploadProvider();
956
+ try {
957
+ await provider.abortMultipartUpload(key, uploadId);
958
+ } catch (error: any) {
959
+ const status = error?.$metadata?.httpStatusCode;
960
+ if (error?.name === 'NoSuchUpload' || error?.Code === 'NoSuchUpload' || status === 404) {
961
+ return;
962
+ }
963
+ throw error;
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Fecha o multipart e registra o arquivo. Confere o que o storage gravou antes de criar
969
+ * a linha — tamanho pelo HeadObject (tem que bater com o declarado no start), teto
970
+ * `storage-max-size`, allowlist `storage-accept-mimetype` e, quando pedido, os primeiros
971
+ * bytes (`verifyHead`) — e apaga o objeto quando algo não confere: um upload recusado
972
+ * não deixa blob órfão no bucket.
973
+ */
974
+ async completeDirectUpload(input: CompleteDirectUploadInput) {
975
+ const { key, uploadId, destination } = input ?? ({} as CompleteDirectUploadInput);
976
+
977
+ if (!destination) {
978
+ throw new BadRequestException(`No destination provided`);
979
+ }
980
+ if (!uploadId) {
981
+ throw new BadRequestException('Invalid direct upload');
982
+ }
983
+
984
+ const storedFilename = this.assertDirectUploadKey(key, destination);
985
+
986
+ const parts = Array.isArray(input.parts) ? input.parts : [];
987
+ if (
988
+ parts.length === 0 ||
989
+ parts.some(
990
+ (part) =>
991
+ !Number.isInteger(Number(part?.partNumber)) ||
992
+ Number(part.partNumber) < 1 ||
993
+ typeof part?.etag !== 'string' ||
994
+ !part.etag.trim(),
995
+ )
996
+ ) {
997
+ throw new BadRequestException('Invalid upload parts');
998
+ }
999
+
1000
+ const provider = await this.getDirectUploadProvider();
1001
+
1002
+ try {
1003
+ await provider.completeMultipartUpload(
1004
+ key,
1005
+ uploadId,
1006
+ parts.map((part) => ({ partNumber: Number(part.partNumber), etag: part.etag.trim() })),
1007
+ );
1008
+ } catch (error: any) {
1009
+ const status = error?.$metadata?.httpStatusCode;
1010
+ if (typeof status === 'number' && status >= 400 && status < 500) {
1011
+ // Parte faltando, ETag errado, parte pequena demais: o multipart não tem conserto.
1012
+ await this.abortDirectUpload(key, uploadId).catch(() => undefined);
1013
+ throw new BadRequestException('The uploaded parts could not be assembled');
1014
+ }
1015
+ throw error;
1016
+ }
1017
+
1018
+ const discard = async () => {
1019
+ await provider.delete(key).catch((error) =>
1020
+ this.logger.warn(`Failed to delete rejected direct upload ${key}: ${error?.message}`),
1021
+ );
1022
+ };
1023
+
1024
+ const declaredSize = Number(input.size);
1025
+ const storedSize = this.getFileSizeFromMetadata(await provider.metaData(key));
1026
+
1027
+ if (storedSize === undefined || storedSize !== declaredSize) {
1028
+ await discard();
1029
+ throw new BadRequestException(
1030
+ `Stored size (${storedSize ?? 'unknown'} bytes) differs from the declared size (${declaredSize} bytes)`,
1031
+ );
1032
+ }
1033
+
1034
+ if (
1035
+ storedSize > FILE_SIZE_COLUMN_MAX ||
1036
+ (!input.skipSizeValidation && !(await this.maxFileSize(storedSize)))
1037
+ ) {
1038
+ await discard();
1039
+ throw new BadRequestException(`File too large: ${storedSize} bytes`);
1040
+ }
1041
+
1042
+ let mimetype = String(input.mimetype ?? '').trim();
1043
+
1044
+ if (input.verifyHead) {
1045
+ try {
1046
+ const verified = await input.verifyHead.check(
1047
+ await provider.readHead(key, input.verifyHead.bytes),
1048
+ );
1049
+ if (typeof verified === 'string' && verified.trim()) {
1050
+ mimetype = verified.trim();
1051
+ }
1052
+ } catch (error) {
1053
+ await discard();
1054
+ throw error;
1055
+ }
1056
+ }
1057
+
1058
+ if (!input.skipMimeValidation && !(await this.acceptMimetypes(mimetype))) {
1059
+ await discard();
1060
+ throw new BadRequestException(`Invalid file type: ${mimetype}`);
1061
+ }
1062
+
1063
+ try {
1064
+ return await this.prismaService.file.create({
1065
+ data: {
1066
+ filename: this.normalizeUploadedFilename(
1067
+ String(input.originalname ?? '').trim() || storedFilename,
1068
+ ),
1069
+ path: storedFilename,
1070
+ provider_id: this.providerId,
1071
+ location: destination,
1072
+ mimetype_id: await this.getMimeType(mimetype),
1073
+ size: storedSize,
1074
+ is_private: input.isPrivate ?? false,
1075
+ created_by_user_id: input.createdByUserId ?? null,
1076
+ },
1077
+ });
1078
+ } catch (error) {
1079
+ await discard();
1080
+ throw error;
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Token opaco que carrega o estado de um upload direto entre as chamadas (sem tabela):
1086
+ * JWT HS256 com o JWT_SECRET da API e o tipo `file-direct-upload`, como o token do
1087
+ * `file-upload-ticket`. Quem chama põe nas claims o que precisa amarrar (marca, usuário).
1088
+ */
1089
+ signDirectUploadToken(
1090
+ claims: Record<string, unknown>,
1091
+ ttlSeconds = DIRECT_UPLOAD_DEFAULT_TTL_SECONDS,
1092
+ ): string {
1093
+ return this.jwtService.sign(
1094
+ { ...claims, t: DIRECT_UPLOAD_TOKEN_TYPE },
1095
+ {
1096
+ secret: process.env.JWT_SECRET,
1097
+ expiresIn: `${Math.max(1, Math.floor(ttlSeconds))}s`,
1098
+ },
1099
+ );
1100
+ }
1101
+
1102
+ /** Claims do token de upload direto; assinatura inválida, expirado ou de outro tipo = 400. */
1103
+ readDirectUploadToken<T extends Record<string, unknown>>(token: string): T {
1104
+ let payload: any;
1105
+
1106
+ try {
1107
+ payload = this.jwtService.verify(String(token ?? ''), { secret: process.env.JWT_SECRET });
1108
+ } catch {
1109
+ throw new BadRequestException('Invalid or expired upload token');
1110
+ }
1111
+
1112
+ if (payload?.t !== DIRECT_UPLOAD_TOKEN_TYPE) {
1113
+ throw new BadRequestException('Token is not a direct upload token');
1114
+ }
1115
+
1116
+ return payload as T;
1117
+ }
1118
+
786
1119
  /**
787
1120
  * Mimetypes de imagem que `optimizeBuffer` devolve intactos por decisão de projeto
788
1121
  * (vetor não vira bitmap, GIF animado não vira quadro único). Enfileirá-los para
@@ -881,7 +1214,7 @@ export class FileService implements OnModuleInit {
881
1214
  }> {
882
1215
  const { file, buffer } = await this.getBuffer(fileId);
883
1216
  const currentMimetype = file.file_mimetype.name;
884
- const oldSize = file.size ?? buffer.length;
1217
+ const oldSize = Number(file.size ?? buffer.length);
885
1218
 
886
1219
  if (!currentMimetype.startsWith('image/')) {
887
1220
  return { changed: false, oldSize, newSize: oldSize, mimetypeChanged: false };
@@ -2014,6 +2347,34 @@ export class FileService implements OnModuleInit {
2014
2347
  });
2015
2348
  }
2016
2349
 
2350
+ /**
2351
+ * Apaga um objeto de bucket avulso, com as credenciais de quem o configurou.
2352
+ *
2353
+ * Mesmo motivo do `headS3Object` e do `signS3Object`: o bucket e de outra
2354
+ * library (o Studio guarda os brutos no perfil de armazenamento dele), e nao o
2355
+ * destino ativo do core. O S3 responde 204 tambem para key inexistente, entao
2356
+ * apagar duas vezes nao e erro.
2357
+ */
2358
+ async deleteS3Object(params: {
2359
+ accessKeyId: string;
2360
+ secretAccessKey: string;
2361
+ sessionToken?: string;
2362
+ region: string;
2363
+ bucket: string;
2364
+ key: string;
2365
+ }): Promise<void> {
2366
+ const s3 = new S3Client({
2367
+ region: params.region,
2368
+ credentials: {
2369
+ accessKeyId: params.accessKeyId,
2370
+ secretAccessKey: params.secretAccessKey,
2371
+ ...(params.sessionToken ? { sessionToken: params.sessionToken } : {}),
2372
+ },
2373
+ });
2374
+
2375
+ await s3.send(new DeleteObjectCommand({ Bucket: params.bucket, Key: params.key }));
2376
+ }
2377
+
2017
2378
  async headS3Bucket(params: {
2018
2379
  accessKeyId: string;
2019
2380
  secretAccessKey: string;
@@ -40,6 +40,47 @@ export function supportsServerSideCopy(
40
40
  return typeof (provider as Partial<ProviderServerSideCopy>).copy === 'function';
41
41
  }
42
42
 
43
+ /** Parte ja enviada de um upload multipart: numero (1-based) e o ETag que o storage devolveu. */
44
+ export type MultipartUploadPart = { partNumber: number; etag: string };
45
+
46
+ /**
47
+ * Upload direto do cliente para o storage, em partes com URL assinada: os bytes nao
48
+ * atravessam a API (nem o teto de 100 MB por requisicao do Cloudflare na frente dela).
49
+ *
50
+ * Opcional como o `copy`: so S3 e Spaces implementam, e quem chama confere com
51
+ * `supportsDirectUpload` antes de oferecer o caminho.
52
+ */
53
+ export interface ProviderDirectUpload {
54
+ createMultipartUpload(key: string, contentType: string): Promise<string>;
55
+ signUploadParts(
56
+ key: string,
57
+ uploadId: string,
58
+ partNumbers: number[],
59
+ ttlSeconds: number,
60
+ ): Promise<Array<{ partNumber: number; url: string }>>;
61
+ completeMultipartUpload(
62
+ key: string,
63
+ uploadId: string,
64
+ parts: MultipartUploadPart[],
65
+ ): Promise<void>;
66
+ abortMultipartUpload(key: string, uploadId: string): Promise<void>;
67
+ /** Os primeiros `bytes` do objeto (Range GET), para conferir o tipo real. */
68
+ readHead(key: string, bytes: number): Promise<Buffer>;
69
+ }
70
+
71
+ export function supportsDirectUpload(
72
+ provider: AbstractProvider,
73
+ ): provider is AbstractProvider & ProviderDirectUpload {
74
+ const candidate = provider as Partial<ProviderDirectUpload>;
75
+ return (
76
+ typeof candidate.createMultipartUpload === 'function' &&
77
+ typeof candidate.signUploadParts === 'function' &&
78
+ typeof candidate.completeMultipartUpload === 'function' &&
79
+ typeof candidate.abortMultipartUpload === 'function' &&
80
+ typeof candidate.readHead === 'function'
81
+ );
82
+ }
83
+
43
84
  export abstract class AbstractProvider implements IProvider {
44
85
  abstract uploadFromString(
45
86
  destination: string,
@@ -67,6 +108,22 @@ export abstract class AbstractProvider implements IProvider {
67
108
  */
68
109
  copy?(sourcePath: string, destination: string, filename: string): Promise<string>;
69
110
 
111
+ /** Upload direto em partes; ver `ProviderDirectUpload` e `supportsDirectUpload`. */
112
+ createMultipartUpload?(key: string, contentType: string): Promise<string>;
113
+ signUploadParts?(
114
+ key: string,
115
+ uploadId: string,
116
+ partNumbers: number[],
117
+ ttlSeconds: number,
118
+ ): Promise<Array<{ partNumber: number; url: string }>>;
119
+ completeMultipartUpload?(
120
+ key: string,
121
+ uploadId: string,
122
+ parts: MultipartUploadPart[],
123
+ ): Promise<void>;
124
+ abortMultipartUpload?(key: string, uploadId: string): Promise<void>;
125
+ readHead?(key: string, bytes: number): Promise<Buffer>;
126
+
70
127
  getExtension(filename: string) {
71
128
  return filename.split('.').pop();
72
129
  }
@@ -0,0 +1,123 @@
1
+ import { afterEach, describe, expect, it, jest } from '@jest/globals';
2
+ import {
3
+ AbortMultipartUploadCommand,
4
+ CompleteMultipartUploadCommand,
5
+ CreateMultipartUploadCommand,
6
+ GetObjectCommand,
7
+ S3Client,
8
+ } from '@aws-sdk/client-s3';
9
+ import { Readable } from 'stream';
10
+ import { supportsDirectUpload } from './abstract.provider';
11
+ import { S3Provider } from './s3.provider';
12
+ import { SpacesProvider } from './spaces.provider';
13
+
14
+ /**
15
+ * Upload multipart assinado no S3 e no Spaces, com o S3Client mockado: o que vai em cada
16
+ * comando, a URL de parte assinada de verdade (o presigner roda offline) e o Range GET.
17
+ */
18
+ const s3Settings = {
19
+ 'storage-s3-key': 'AKIAEXAMPLE',
20
+ 'storage-s3-secret': 'secret',
21
+ 'storage-s3-region': 'us-east-1',
22
+ 'storage-s3-bucket': 'bucket-s3',
23
+ };
24
+
25
+ const spacesSettings = {
26
+ 'storage-spaces-key': 'DOEXAMPLE',
27
+ 'storage-spaces-secret': 'secret',
28
+ 'storage-spaces-region': 'nyc3',
29
+ 'storage-spaces-bucket': 'spaces-hub',
30
+ };
31
+
32
+ function mockSend(impl: (command: any) => any) {
33
+ return jest.spyOn(S3Client.prototype, 'send').mockImplementation(async (command: any) => impl(command));
34
+ }
35
+
36
+ type ProviderCase = [string, () => any, string, string | undefined, string];
37
+
38
+ describe.each<ProviderCase>([
39
+ ['S3', () => new S3Provider(s3Settings), 'bucket-s3', undefined, 'amazonaws.com'],
40
+ ['Spaces', () => new SpacesProvider(spacesSettings), 'spaces-hub', 'public-read', 'digitaloceanspaces.com'],
41
+ ])('%s provider — upload direto', (_name, build, bucket, acl, host) => {
42
+ afterEach(() => jest.restoreAllMocks());
43
+
44
+ it('implementa o contrato de upload direto', () => {
45
+ expect(supportsDirectUpload(build())).toBe(true);
46
+ });
47
+
48
+ it('createMultipartUpload manda bucket, chave e tipo e devolve o UploadId', async () => {
49
+ const send = mockSend(() => ({ UploadId: 'up-1' }));
50
+
51
+ await expect(build().createMultipartUpload('social/brands/1/a.mp4', 'video/mp4')).resolves.toBe('up-1');
52
+
53
+ const command = send.mock.calls[0][0] as any;
54
+ expect(command).toBeInstanceOf(CreateMultipartUploadCommand);
55
+ expect(command.input).toEqual({
56
+ Bucket: bucket,
57
+ Key: 'social/brands/1/a.mp4',
58
+ ContentType: 'video/mp4',
59
+ ...(acl ? { ACL: acl } : {}),
60
+ });
61
+ });
62
+
63
+ it('createMultipartUpload sem UploadId é erro', async () => {
64
+ mockSend(() => ({}));
65
+ await expect(build().createMultipartUpload('k', 'video/mp4')).rejects.toThrow('multipart upload id');
66
+ });
67
+
68
+ it('signUploadParts devolve uma URL assinada de PUT por parte', async () => {
69
+ const parts = await build().signUploadParts('social/brands/1/a.mp4', 'up-1', [1, 2], 3600);
70
+
71
+ expect(parts.map((p) => p.partNumber)).toEqual([1, 2]);
72
+ for (const part of parts) {
73
+ const url = new URL(part.url);
74
+ expect(url.host).toContain(host);
75
+ expect(url.searchParams.get('uploadId')).toBe('up-1');
76
+ expect(url.searchParams.get('partNumber')).toBe(String(part.partNumber));
77
+ expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
78
+ expect(url.searchParams.get('X-Amz-Signature')).toBeTruthy();
79
+ }
80
+ });
81
+
82
+ it('completeMultipartUpload ordena as partes pelo número', async () => {
83
+ const send = mockSend(() => ({}));
84
+
85
+ await build().completeMultipartUpload('k', 'up-1', [
86
+ { partNumber: 2, etag: '"b"' },
87
+ { partNumber: 1, etag: '"a"' },
88
+ ]);
89
+
90
+ const command = send.mock.calls[0][0] as any;
91
+ expect(command).toBeInstanceOf(CompleteMultipartUploadCommand);
92
+ expect(command.input).toEqual({
93
+ Bucket: bucket,
94
+ Key: 'k',
95
+ UploadId: 'up-1',
96
+ MultipartUpload: {
97
+ Parts: [
98
+ { PartNumber: 1, ETag: '"a"' },
99
+ { PartNumber: 2, ETag: '"b"' },
100
+ ],
101
+ },
102
+ });
103
+ });
104
+
105
+ it('abortMultipartUpload aborta o upload da chave', async () => {
106
+ const send = mockSend(() => ({}));
107
+ await build().abortMultipartUpload('k', 'up-1');
108
+ const command = send.mock.calls[0][0] as any;
109
+ expect(command).toBeInstanceOf(AbortMultipartUploadCommand);
110
+ expect(command.input).toEqual({ Bucket: bucket, Key: 'k', UploadId: 'up-1' });
111
+ });
112
+
113
+ it('readHead pede só os primeiros bytes e corta o que vier a mais', async () => {
114
+ const send = mockSend(() => ({ Body: Readable.from([Buffer.from('0123456789'), Buffer.from('abc')]) }));
115
+
116
+ const head = await build().readHead('k', 4);
117
+
118
+ const command = send.mock.calls[0][0] as any;
119
+ expect(command).toBeInstanceOf(GetObjectCommand);
120
+ expect(command.input).toEqual({ Bucket: bucket, Key: 'k', Range: 'bytes=0-3' });
121
+ expect(head.toString()).toBe('0123');
122
+ });
123
+ });