@hed-hog/core 0.0.401 → 0.0.404
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-log/access-log.interceptor.d.ts +10 -0
- package/dist/access-log/access-log.interceptor.d.ts.map +1 -1
- package/dist/access-log/access-log.interceptor.js +15 -1
- package/dist/access-log/access-log.interceptor.js.map +1 -1
- package/dist/authorize/authorize.controller.d.ts +1 -0
- package/dist/authorize/authorize.controller.d.ts.map +1 -1
- package/dist/authorize/authorize.service.d.ts +1 -0
- package/dist/authorize/authorize.service.d.ts.map +1 -1
- package/dist/authorize/authorize.service.js +3 -0
- package/dist/authorize/authorize.service.js.map +1 -1
- package/dist/file/file-upload-ticket.service.d.ts +1 -1
- package/dist/file/file.controller.d.ts +3 -3
- package/dist/file/file.service.d.ts +121 -12
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +245 -3
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +33 -0
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js +9 -0
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/s3-multipart.util.d.ts +17 -0
- package/dist/file/provider/s3-multipart.util.d.ts.map +1 -0
- package/dist/file/provider/s3-multipart.util.js +88 -0
- package/dist/file/provider/s3-multipart.util.js.map +1 -0
- package/dist/file/provider/s3.provider.d.ts +9 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +16 -0
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +10 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +17 -0
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +1 -0
- package/dist/integration-profile/integration-profile.controller.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +7 -2
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts +1 -0
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +3 -3
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/testers/http-probe.d.ts.map +1 -1
- package/dist/integration-profile/testers/http-probe.js +5 -3
- package/dist/integration-profile/testers/http-probe.js.map +1 -1
- package/dist/integration-profile/testers/integration-tester.types.d.ts +5 -0
- package/dist/integration-profile/testers/integration-tester.types.d.ts.map +1 -1
- package/dist/mail/mail.service.d.ts.map +1 -1
- package/dist/mail/mail.service.js +16 -1
- package/dist/mail/mail.service.js.map +1 -1
- package/dist/mcp/mcp-server.service.d.ts.map +1 -1
- package/dist/mcp/mcp-server.service.js +10 -1
- package/dist/mcp/mcp-server.service.js.map +1 -1
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -1
- package/dist/pdf/document/pdf-document.css.js +88 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -1
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -1
- package/dist/pdf/document/pdf-document.renderer.js +73 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -1
- package/dist/pdf/document/pdf-document.types.d.ts +56 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -1
- package/dist/pdf/pdf.service.d.ts +6 -0
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +3 -0
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +3 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +6 -0
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +44 -13
- package/dist/task/task.service.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts +35 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +83 -21
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.test.tsx.ejs +85 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +576 -491
- package/hedhog/frontend/app/users/simulate-access-dialog.test.tsx.ejs +29 -50
- package/hedhog/frontend/app/users/simulate-access-dialog.tsx.ejs +146 -50
- package/hedhog/frontend/messages/en.json +46 -20
- package/hedhog/frontend/messages/pt.json +46 -20
- package/hedhog/table/file.yaml +3 -1
- package/package.json +3 -3
- package/src/access-log/access-log.interceptor.spec.ts +50 -0
- package/src/access-log/access-log.interceptor.ts +18 -0
- package/src/authorize/authorize.service.spec.ts +1 -0
- package/src/authorize/authorize.service.ts +2 -0
- package/src/file/file-direct-upload.spec.ts +344 -0
- package/src/file/file.service.ts +363 -2
- package/src/file/provider/abstract.provider.ts +57 -0
- package/src/file/provider/s3-multipart.spec.ts +123 -0
- package/src/file/provider/s3-multipart.util.ts +126 -0
- package/src/file/provider/s3.provider.ts +65 -1
- package/src/file/provider/spaces.provider.ts +67 -1
- package/src/integration-profile/integration-profile.secrets.ts +7 -2
- package/src/integration-profile/integration-profile.service.ts +1 -0
- package/src/integration-profile/testers/http-probe.ts +5 -3
- package/src/integration-profile/testers/integration-tester.types.ts +5 -0
- package/src/integration-profile/testers/oauth.testers.spec.ts +13 -0
- package/src/integration-profile/testers/tester-coverage.spec.ts +2 -0
- package/src/mail/mail.service.spec.ts +25 -0
- package/src/mail/mail.service.ts +18 -1
- package/src/mcp/mcp-server.service.spec.ts +16 -0
- package/src/mcp/mcp-server.service.ts +10 -1
- package/src/pdf/document/pdf-document.css.ts +88 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +43 -0
- package/src/pdf/document/pdf-document.renderer.ts +102 -0
- package/src/pdf/document/pdf-document.types.ts +52 -0
- package/src/pdf/pdf.service.ts +11 -0
- package/src/setting/setting.service.ts +3 -0
- package/src/task/task.service.spec.ts +77 -1
- package/src/task/task.service.ts +58 -14
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +191 -1
- package/src/whatsapp-sender/whatsapp-sender.service.ts +122 -14
package/src/file/file.service.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|