@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AbortMultipartUploadCommand,
|
|
3
|
+
CompleteMultipartUploadCommand,
|
|
4
|
+
CreateMultipartUploadCommand,
|
|
5
|
+
GetObjectCommand,
|
|
6
|
+
ObjectCannedACL,
|
|
7
|
+
S3Client,
|
|
8
|
+
UploadPartCommand,
|
|
9
|
+
} from '@aws-sdk/client-s3';
|
|
10
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
11
|
+
import { Readable } from 'stream';
|
|
12
|
+
import { MultipartUploadPart } from './abstract.provider';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Upload multipart assinado, comum ao S3 e ao Spaces (mesma API): o servidor abre,
|
|
16
|
+
* assina e fecha; o cliente so recebe URLs que valem para uma parte e expiram.
|
|
17
|
+
* Mesmo desenho de libraries/studio/src/common/studio-s3-compatible.client.ts.
|
|
18
|
+
*/
|
|
19
|
+
export async function s3CreateMultipartUpload(
|
|
20
|
+
client: S3Client,
|
|
21
|
+
bucket: string,
|
|
22
|
+
key: string,
|
|
23
|
+
contentType: string,
|
|
24
|
+
acl?: ObjectCannedACL,
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const result = await client.send(
|
|
27
|
+
new CreateMultipartUploadCommand({
|
|
28
|
+
Bucket: bucket,
|
|
29
|
+
Key: key,
|
|
30
|
+
ContentType: contentType,
|
|
31
|
+
...(acl ? { ACL: acl } : {}),
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (!result.UploadId) {
|
|
36
|
+
throw new Error('The storage did not return a multipart upload id.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result.UploadId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function s3SignUploadParts(
|
|
43
|
+
client: S3Client,
|
|
44
|
+
bucket: string,
|
|
45
|
+
key: string,
|
|
46
|
+
uploadId: string,
|
|
47
|
+
partNumbers: number[],
|
|
48
|
+
ttlSeconds: number,
|
|
49
|
+
): Promise<Array<{ partNumber: number; url: string }>> {
|
|
50
|
+
return Promise.all(
|
|
51
|
+
partNumbers.map(async (partNumber) => ({
|
|
52
|
+
partNumber,
|
|
53
|
+
url: await getSignedUrl(
|
|
54
|
+
client as any,
|
|
55
|
+
new UploadPartCommand({
|
|
56
|
+
Bucket: bucket,
|
|
57
|
+
Key: key,
|
|
58
|
+
UploadId: uploadId,
|
|
59
|
+
PartNumber: partNumber,
|
|
60
|
+
}),
|
|
61
|
+
{ expiresIn: ttlSeconds },
|
|
62
|
+
),
|
|
63
|
+
})),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function s3CompleteMultipartUpload(
|
|
68
|
+
client: S3Client,
|
|
69
|
+
bucket: string,
|
|
70
|
+
key: string,
|
|
71
|
+
uploadId: string,
|
|
72
|
+
parts: MultipartUploadPart[],
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
await client.send(
|
|
75
|
+
new CompleteMultipartUploadCommand({
|
|
76
|
+
Bucket: bucket,
|
|
77
|
+
Key: key,
|
|
78
|
+
UploadId: uploadId,
|
|
79
|
+
MultipartUpload: {
|
|
80
|
+
Parts: [...parts]
|
|
81
|
+
.sort((a, b) => a.partNumber - b.partNumber)
|
|
82
|
+
.map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })),
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function s3AbortMultipartUpload(
|
|
89
|
+
client: S3Client,
|
|
90
|
+
bucket: string,
|
|
91
|
+
key: string,
|
|
92
|
+
uploadId: string,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
await client.send(
|
|
95
|
+
new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Range GET dos primeiros `bytes`: basta para o sniff do tipo, sem baixar o objeto. */
|
|
100
|
+
export async function s3ReadHead(
|
|
101
|
+
client: S3Client,
|
|
102
|
+
bucket: string,
|
|
103
|
+
key: string,
|
|
104
|
+
bytes: number,
|
|
105
|
+
): Promise<Buffer> {
|
|
106
|
+
const length = Math.max(1, Math.floor(bytes));
|
|
107
|
+
const result = await client.send(
|
|
108
|
+
new GetObjectCommand({ Bucket: bucket, Key: key, Range: `bytes=0-${length - 1}` }),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const stream = result.Body as Readable;
|
|
112
|
+
const chunks: Buffer[] = [];
|
|
113
|
+
let total = 0;
|
|
114
|
+
for await (const chunk of stream) {
|
|
115
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
116
|
+
chunks.push(buffer);
|
|
117
|
+
total += buffer.length;
|
|
118
|
+
// Storage que ignora o Range devolveria o objeto inteiro: para no que basta.
|
|
119
|
+
if (total >= length) {
|
|
120
|
+
stream.destroy?.();
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Buffer.concat(chunks).subarray(0, length);
|
|
126
|
+
}
|
|
@@ -14,7 +14,14 @@ import { BadRequestException } from '@nestjs/common';
|
|
|
14
14
|
import { createReadStream } from 'fs';
|
|
15
15
|
import { basename } from 'path';
|
|
16
16
|
import { Readable } from 'stream';
|
|
17
|
-
import { AbstractProvider } from './abstract.provider';
|
|
17
|
+
import { AbstractProvider, MultipartUploadPart } from './abstract.provider';
|
|
18
|
+
import {
|
|
19
|
+
s3AbortMultipartUpload,
|
|
20
|
+
s3CompleteMultipartUpload,
|
|
21
|
+
s3CreateMultipartUpload,
|
|
22
|
+
s3ReadHead,
|
|
23
|
+
s3SignUploadParts,
|
|
24
|
+
} from './s3-multipart.util';
|
|
18
25
|
|
|
19
26
|
export class S3Provider extends AbstractProvider {
|
|
20
27
|
private S3: S3 | undefined;
|
|
@@ -253,4 +260,61 @@ export class S3Provider extends AbstractProvider {
|
|
|
253
260
|
// Use S3Client instance for getSignedUrl
|
|
254
261
|
return await getSignedUrl(s3Client as any, command, { expiresIn: expires });
|
|
255
262
|
}
|
|
263
|
+
|
|
264
|
+
async createMultipartUpload(key: string, contentType: string): Promise<string> {
|
|
265
|
+
return s3CreateMultipartUpload(
|
|
266
|
+
await this.getS3Client(),
|
|
267
|
+
this.setting['storage-s3-bucket'],
|
|
268
|
+
this.resolveS3Key(key),
|
|
269
|
+
contentType,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async signUploadParts(
|
|
274
|
+
key: string,
|
|
275
|
+
uploadId: string,
|
|
276
|
+
partNumbers: number[],
|
|
277
|
+
ttlSeconds: number,
|
|
278
|
+
) {
|
|
279
|
+
return s3SignUploadParts(
|
|
280
|
+
await this.getS3Client(),
|
|
281
|
+
this.setting['storage-s3-bucket'],
|
|
282
|
+
this.resolveS3Key(key),
|
|
283
|
+
uploadId,
|
|
284
|
+
partNumbers,
|
|
285
|
+
ttlSeconds,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async completeMultipartUpload(
|
|
290
|
+
key: string,
|
|
291
|
+
uploadId: string,
|
|
292
|
+
parts: MultipartUploadPart[],
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
await s3CompleteMultipartUpload(
|
|
295
|
+
await this.getS3Client(),
|
|
296
|
+
this.setting['storage-s3-bucket'],
|
|
297
|
+
this.resolveS3Key(key),
|
|
298
|
+
uploadId,
|
|
299
|
+
parts,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
|
|
304
|
+
await s3AbortMultipartUpload(
|
|
305
|
+
await this.getS3Client(),
|
|
306
|
+
this.setting['storage-s3-bucket'],
|
|
307
|
+
this.resolveS3Key(key),
|
|
308
|
+
uploadId,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async readHead(key: string, bytes: number): Promise<Buffer> {
|
|
313
|
+
return s3ReadHead(
|
|
314
|
+
await this.getS3Client(),
|
|
315
|
+
this.setting['storage-s3-bucket'],
|
|
316
|
+
this.resolveS3Key(key),
|
|
317
|
+
bytes,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
256
320
|
}
|
|
@@ -14,7 +14,14 @@ import { BadRequestException } from '@nestjs/common';
|
|
|
14
14
|
import { createReadStream } from 'fs';
|
|
15
15
|
import { basename } from 'path';
|
|
16
16
|
import { Readable } from 'stream';
|
|
17
|
-
import { AbstractProvider } from './abstract.provider';
|
|
17
|
+
import { AbstractProvider, MultipartUploadPart } from './abstract.provider';
|
|
18
|
+
import {
|
|
19
|
+
s3AbortMultipartUpload,
|
|
20
|
+
s3CompleteMultipartUpload,
|
|
21
|
+
s3CreateMultipartUpload,
|
|
22
|
+
s3ReadHead,
|
|
23
|
+
s3SignUploadParts,
|
|
24
|
+
} from './s3-multipart.util';
|
|
18
25
|
|
|
19
26
|
export class SpacesProvider extends AbstractProvider {
|
|
20
27
|
private S3: S3 | undefined;
|
|
@@ -280,4 +287,63 @@ export class SpacesProvider extends AbstractProvider {
|
|
|
280
287
|
});
|
|
281
288
|
return await getSignedUrl(s3Client as any, command, { expiresIn: expires });
|
|
282
289
|
}
|
|
290
|
+
|
|
291
|
+
/** Mesmo ACL dos demais uploads deste provider (o objeto nasce com ele no create). */
|
|
292
|
+
async createMultipartUpload(key: string, contentType: string): Promise<string> {
|
|
293
|
+
return s3CreateMultipartUpload(
|
|
294
|
+
await this.getS3Client(),
|
|
295
|
+
this.setting['storage-spaces-bucket'],
|
|
296
|
+
this.resolveKey(key),
|
|
297
|
+
contentType,
|
|
298
|
+
'public-read',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async signUploadParts(
|
|
303
|
+
key: string,
|
|
304
|
+
uploadId: string,
|
|
305
|
+
partNumbers: number[],
|
|
306
|
+
ttlSeconds: number,
|
|
307
|
+
) {
|
|
308
|
+
return s3SignUploadParts(
|
|
309
|
+
await this.getS3Client(),
|
|
310
|
+
this.setting['storage-spaces-bucket'],
|
|
311
|
+
this.resolveKey(key),
|
|
312
|
+
uploadId,
|
|
313
|
+
partNumbers,
|
|
314
|
+
ttlSeconds,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async completeMultipartUpload(
|
|
319
|
+
key: string,
|
|
320
|
+
uploadId: string,
|
|
321
|
+
parts: MultipartUploadPart[],
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
await s3CompleteMultipartUpload(
|
|
324
|
+
await this.getS3Client(),
|
|
325
|
+
this.setting['storage-spaces-bucket'],
|
|
326
|
+
this.resolveKey(key),
|
|
327
|
+
uploadId,
|
|
328
|
+
parts,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async abortMultipartUpload(key: string, uploadId: string): Promise<void> {
|
|
333
|
+
await s3AbortMultipartUpload(
|
|
334
|
+
await this.getS3Client(),
|
|
335
|
+
this.setting['storage-spaces-bucket'],
|
|
336
|
+
this.resolveKey(key),
|
|
337
|
+
uploadId,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async readHead(key: string, bytes: number): Promise<Buffer> {
|
|
342
|
+
return s3ReadHead(
|
|
343
|
+
await this.getS3Client(),
|
|
344
|
+
this.setting['storage-spaces-bucket'],
|
|
345
|
+
this.resolveKey(key),
|
|
346
|
+
bytes,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
283
349
|
}
|
|
@@ -85,8 +85,9 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
|
|
|
85
85
|
// App do Instagram (Instagram API with Instagram Login) — o segredo do app do
|
|
86
86
|
// INSTAGRAM, nao o do app da Meta. Troca o code do OAuth pelo token de
|
|
87
87
|
// qualquer conta que autorize o app; os tokens de 60 dias das contas ficam
|
|
88
|
-
// cifrados em social_channel.
|
|
89
|
-
|
|
88
|
+
// cifrados em social_channel. O webhook_verify_token e comparado no
|
|
89
|
+
// handshake do webhook do Instagram (inbox de comentarios e DMs).
|
|
90
|
+
'instagram-social': ['app_secret', 'webhook_verify_token'],
|
|
90
91
|
// Apps do Google para o modulo social (um projeto por rede). O client_secret
|
|
91
92
|
// troca o code do OAuth pelo refresh token de qualquer canal/local que
|
|
92
93
|
// autorize o app; os refresh tokens ficam cifrados em social_channel.
|
|
@@ -97,6 +98,10 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
|
|
|
97
98
|
// pagina que autorize o app; as credenciais das paginas (envelope lir1:/lia1:)
|
|
98
99
|
// ficam cifradas em social_channel.
|
|
99
100
|
'linkedin-social': ['client_secret'],
|
|
101
|
+
// App do X (OAuth 2.0, cliente confidencial). O client_secret vai no header
|
|
102
|
+
// Basic da troca do code, da renovacao e da revogacao; os tokens de cada
|
|
103
|
+
// conta (que rotacionam) ficam cifrados em social_channel_oauth_token.
|
|
104
|
+
'x-social': ['client_secret'],
|
|
100
105
|
};
|
|
101
106
|
|
|
102
107
|
/** Chaves sensíveis de um provider (vazio se desconhecido). */
|
|
@@ -544,6 +544,7 @@ export class IntegrationProfileService {
|
|
|
544
544
|
provider: provider.slug,
|
|
545
545
|
...(outcome.info ? { info: outcome.info } : {}),
|
|
546
546
|
...(outcome.warning ? { warning: outcome.warning } : {}),
|
|
547
|
+
...(outcome.warningCodes?.length ? { warning_codes: outcome.warningCodes } : {}),
|
|
547
548
|
tested_at: new Date().toISOString(),
|
|
548
549
|
persisted,
|
|
549
550
|
};
|
|
@@ -81,16 +81,18 @@ export async function probeJson(
|
|
|
81
81
|
signal: AbortSignal,
|
|
82
82
|
): Promise<ProbeResult> {
|
|
83
83
|
const response = await fetch(url, { ...init, signal });
|
|
84
|
-
const
|
|
84
|
+
const raw = await response.text().catch(() => '');
|
|
85
85
|
|
|
86
|
+
// O parse usa o corpo inteiro: um access token JWT (ex.: o do Graph com muitas
|
|
87
|
+
// permissões de aplicação) passa fácil de 2 KB, e o JSON truncado viraria `{}`.
|
|
86
88
|
let body: any = {};
|
|
87
89
|
try {
|
|
88
|
-
body =
|
|
90
|
+
body = raw ? JSON.parse(raw) : {};
|
|
89
91
|
} catch {
|
|
90
92
|
body = {};
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
return { ok: response.ok, status: response.status, body, text };
|
|
95
|
+
return { ok: response.ok, status: response.status, body, text: raw.slice(0, 2_000) };
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
/** Remove qualquer ocorrência dos segredos do texto. */
|
|
@@ -26,6 +26,11 @@ export type IntegrationTestOutcome = {
|
|
|
26
26
|
* verificado, service account sem acesso ao app, pool inexistente). Não reprova.
|
|
27
27
|
*/
|
|
28
28
|
warning?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Códigos estáveis dos avisos, na ordem do `warning`, para a UI traduzir. O
|
|
31
|
+
* `warning` continua sendo o texto de fallback de quem não conhece o código.
|
|
32
|
+
*/
|
|
33
|
+
warningCodes?: string[];
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
export type IntegrationTestContext = {
|
|
@@ -126,6 +126,19 @@ describe('microsoft-oauth tester', () => {
|
|
|
126
126
|
expect(outcome.info).toContain('contoso');
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
it('aprova quando o token é maior que o limite de texto guardado', async () => {
|
|
130
|
+
fetchMock.mockResolvedValue(
|
|
131
|
+
jsonResponse({ token_type: 'Bearer', access_token: 'x'.repeat(5_000) }),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const outcome = await tester.test(
|
|
135
|
+
{ client_id: 'a', client_secret: 'b', tenant_id: 'contoso' },
|
|
136
|
+
ctx(),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(outcome.ok).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
129
142
|
it.each([
|
|
130
143
|
['AADSTS7000215', 'Invalid client secret.'],
|
|
131
144
|
['AADSTS700016', 'Application not found in tenant "contoso".'],
|
|
@@ -25,6 +25,8 @@ const EXTERNAL_PROVIDERS = new Set([
|
|
|
25
25
|
'google-business-social',
|
|
26
26
|
// App do LinkedIn (paginas de empresa): o tester vive em libraries/social.
|
|
27
27
|
'linkedin-social',
|
|
28
|
+
// App do X (OAuth 2.0 com PKCE): o tester vive em libraries/social.
|
|
29
|
+
'x-social',
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
32
|
/** Providers legítimos sem nenhum campo secreto. */
|
|
@@ -82,6 +82,31 @@ describe('MailService', () => {
|
|
|
82
82
|
expect(call('A\r\nB', {})).toBe('A<br/>B');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
// Bug real: o aviso interno do SAC, escrito em HTML indentado, chegava com
|
|
86
|
+
// um vao entre cada campo — cada quebra entre dois <p> virava <br/>.
|
|
87
|
+
it('não gera <br/> na quebra que só separa uma tag da seguinte', () => {
|
|
88
|
+
expect(call('<div>\n <p>A</p>\n <p>B</p>\n</div>', {})).toBe(
|
|
89
|
+
'<div><p>A</p><p>B</p></div>',
|
|
90
|
+
);
|
|
91
|
+
expect(call('<p>A</p>\r\n\r\n<p>B</p>', {})).toBe('<p>A</p><p>B</p>');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('não gera <br/> entre tag e bloco {{#if}}/{{/if}}', () => {
|
|
95
|
+
const src =
|
|
96
|
+
'<p>A</p>\n{{#if x}}<p>X</p>{{/if}}\n<p>B</p>\n{{#if y}}\n <p>Y</p>\n{{else}}\n <p>N</p>\n{{/if}}';
|
|
97
|
+
expect(call(src, { x: true })).toBe('<p>A</p><p>X</p><p>B</p><p>N</p>');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('mantém o <br/> da quebra que vem depois de texto ou de variável', () => {
|
|
101
|
+
expect(call('Olá,\n<div>x</div>', {})).toBe('Olá,<br/><div>x</div>');
|
|
102
|
+
expect(call('Oi {{name}}\n<p>x</p>', { name: 'Bob' })).toBe(
|
|
103
|
+
'Oi Bob<br/><p>x</p>',
|
|
104
|
+
);
|
|
105
|
+
expect(call('<strong>A</strong>\ntexto', {})).toBe(
|
|
106
|
+
'<strong>A</strong><br/>texto',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
85
110
|
it('preserva as quebras de linha de um documento HTML completo (não injeta <br/> dentro de <style>/<head>)', () => {
|
|
86
111
|
// Bug real: um HTML completo colado em Templates de e-mail (que
|
|
87
112
|
// funciona no preview cru de Campanhas) aparecia quebrado aqui porque
|
package/src/mail/mail.service.ts
CHANGED
|
@@ -44,6 +44,14 @@ import { IncidentService } from '../incident/incident.service';
|
|
|
44
44
|
|
|
45
45
|
export type { SendTemplatedMailResult } from './dto/send.dto';
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Quebra de linha (com a indentacao em volta) entre o fim de uma tag ou de um
|
|
49
|
+
* bloco do Handlebars e o comeco da proxima tag ou bloco. `{{variavel}}` nao
|
|
50
|
+
* conta como bloco: depois dela vem texto, e a quebra e do autor.
|
|
51
|
+
*/
|
|
52
|
+
const TAG_TO_TAG_LINE_BREAK =
|
|
53
|
+
/(>|\{\{[#/][^}]*\}\}|\{\{else\}\})[ \t]*(?:\r\n|\n|\r)\s*(?=<|\{\{[#/]|\{\{else\}\})/g;
|
|
54
|
+
|
|
47
55
|
@Injectable()
|
|
48
56
|
export class MailService implements OnModuleInit {
|
|
49
57
|
private readonly logger = new Logger(MailService.name);
|
|
@@ -692,9 +700,18 @@ export class MailService implements OnModuleInit {
|
|
|
692
700
|
// caso; aqui só espelhamos a mesma detecção pra não mutilar antes
|
|
693
701
|
// disso). Emails simples (sem <html>) continuam ganhando <br/> pra
|
|
694
702
|
// preservar quebras de linha digitadas pelo usuário.
|
|
703
|
+
//
|
|
704
|
+
// A quebra que só separa uma tag (ou um bloco `{{#if}}`/`{{/if}}`) da
|
|
705
|
+
// seguinte é indentação do template, não texto: virar <br/> ali punha
|
|
706
|
+
// uma linha em branco entre cada <p> de todo template escrito em HTML
|
|
707
|
+
// de várias linhas. Essa sai antes; a que vem depois de texto continua.
|
|
708
|
+
// A quebra final (todo bloco `|` do YAML termina em uma) também sai.
|
|
695
709
|
const normalized = isFullHtmlDocument(source)
|
|
696
710
|
? source
|
|
697
|
-
: source
|
|
711
|
+
: source
|
|
712
|
+
.trimEnd()
|
|
713
|
+
.replace(TAG_TO_TAG_LINE_BREAK, '$1')
|
|
714
|
+
.replace(/(\r\n|\n|\r)/g, '<br/>');
|
|
698
715
|
const template = Handlebars.compile(normalized, { noEscape: !escapeHtml });
|
|
699
716
|
return template(data);
|
|
700
717
|
} catch (error: any) {
|
|
@@ -195,6 +195,22 @@ describe('McpServerService', () => {
|
|
|
195
195
|
);
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
it('handler void responde { success: true } em vez de um text ausente', async () => {
|
|
199
|
+
const { service, deps } = makeService();
|
|
200
|
+
const handler = jest.fn<any>().mockResolvedValue(undefined);
|
|
201
|
+
deps.registry.getAll.mockReturnValue([]);
|
|
202
|
+
deps.registry.get.mockReturnValue({ definition: { name: 'tool-a' }, handler });
|
|
203
|
+
|
|
204
|
+
(service as any).buildServer(context);
|
|
205
|
+
const callHandler = getRegisteredHandler(CallToolRequestSchema);
|
|
206
|
+
|
|
207
|
+
const result = await callHandler({ params: { name: 'tool-a', arguments: {} } });
|
|
208
|
+
|
|
209
|
+
expect(result).toEqual({
|
|
210
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true }) }],
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
198
214
|
it('trata argumentos ausentes como objeto vazio', async () => {
|
|
199
215
|
const { service, deps } = makeService();
|
|
200
216
|
const handler = jest.fn<any>().mockResolvedValue({});
|
|
@@ -88,7 +88,16 @@ export class McpServerService {
|
|
|
88
88
|
success: true,
|
|
89
89
|
durationMs: Date.now() - start,
|
|
90
90
|
});
|
|
91
|
-
|
|
91
|
+
// Handler `void` (ex.: um delete) dá `JSON.stringify(undefined) === undefined`, e o
|
|
92
|
+
// cliente MCP recusa o resultado inteiro por faltar `text` — mesmo com a ação feita.
|
|
93
|
+
return {
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: 'text',
|
|
97
|
+
text: result === undefined ? JSON.stringify({ success: true }) : JSON.stringify(result),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
92
101
|
} catch (e: any) {
|
|
93
102
|
await this.audit.record({
|
|
94
103
|
userId: context.userId,
|
|
@@ -234,5 +234,93 @@ export function buildDocumentCss(theme: PdfDocumentTheme, landscape = false): st
|
|
|
234
234
|
}
|
|
235
235
|
.legend p { margin: 0 0 3px; }
|
|
236
236
|
.page-break { break-before: page; }
|
|
237
|
+
|
|
238
|
+
/* ── Barra empilhada e cartões de progresso ── */
|
|
239
|
+
.seg-bar {
|
|
240
|
+
display: flex;
|
|
241
|
+
height: 6px;
|
|
242
|
+
border-radius: 999px;
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
background: ${line};
|
|
245
|
+
margin: 8px 0;
|
|
246
|
+
}
|
|
247
|
+
.seg { display: block; height: 100%; }
|
|
248
|
+
.seg-ink, .dot-ink { background: ${ink}; }
|
|
249
|
+
.seg-muted, .dot-muted { background: #8f8f94; }
|
|
250
|
+
.seg-light, .dot-light { background: #cfcfd4; }
|
|
251
|
+
.seg-accent, .dot-accent { background: ${primary}; }
|
|
252
|
+
.seg-danger, .dot-danger { background: #dc2626; }
|
|
253
|
+
.dot {
|
|
254
|
+
display: inline-block;
|
|
255
|
+
width: 7px;
|
|
256
|
+
height: 7px;
|
|
257
|
+
border-radius: 50%;
|
|
258
|
+
margin-right: 5px;
|
|
259
|
+
vertical-align: middle;
|
|
260
|
+
}
|
|
261
|
+
.seg-block {
|
|
262
|
+
border: 1px solid ${line};
|
|
263
|
+
border-radius: 12px;
|
|
264
|
+
padding: 12px 16px;
|
|
265
|
+
margin-bottom: 18px;
|
|
266
|
+
break-inside: avoid;
|
|
267
|
+
}
|
|
268
|
+
.seg-title { font-size: 13px; font-weight: 700; color: ${ink}; }
|
|
269
|
+
.seg-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 10px; color: ${ink}; }
|
|
270
|
+
.seg-legend-item { white-space: nowrap; }
|
|
271
|
+
.seg-caption { font-size: 10.5px; color: ${ink}; margin-top: 8px; }
|
|
272
|
+
.pc-legend {
|
|
273
|
+
display: flex;
|
|
274
|
+
flex-wrap: wrap;
|
|
275
|
+
gap: 6px 18px;
|
|
276
|
+
font-size: 10px;
|
|
277
|
+
color: ${mutedFg};
|
|
278
|
+
margin-bottom: 12px;
|
|
279
|
+
}
|
|
280
|
+
.pc-grid { display: grid; gap: 12px; align-items: start; }
|
|
281
|
+
.pc-grid-1 { grid-template-columns: 1fr; }
|
|
282
|
+
.pc-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
|
283
|
+
.pc-card {
|
|
284
|
+
border: 1px solid ${line};
|
|
285
|
+
border-radius: 12px;
|
|
286
|
+
padding: 12px 14px;
|
|
287
|
+
break-inside: avoid;
|
|
288
|
+
}
|
|
289
|
+
.pc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
|
290
|
+
.pc-title { font-size: 13px; font-weight: 700; color: ${ink}; line-height: 1.25; }
|
|
291
|
+
.pc-subtitle { font-size: 9.5px; color: ${mutedFg}; margin-top: 2px; }
|
|
292
|
+
.pc-value { text-align: right; white-space: nowrap; }
|
|
293
|
+
.pc-value-num { font-size: 22px; font-weight: 700; color: ${ink}; line-height: 1; }
|
|
294
|
+
.pc-value-hint { font-size: 9px; color: ${mutedFg}; margin-top: 2px; }
|
|
295
|
+
.pc-list-title { font-size: 10px; font-weight: 700; color: ${ink}; margin: 4px 0 6px; }
|
|
296
|
+
.pc-entry {
|
|
297
|
+
border: 1px solid ${line};
|
|
298
|
+
border-radius: 8px;
|
|
299
|
+
padding: 6px 10px;
|
|
300
|
+
margin-bottom: 6px;
|
|
301
|
+
break-inside: avoid;
|
|
302
|
+
}
|
|
303
|
+
.pc-entry-hl { border-color: transparent; }
|
|
304
|
+
.pc-entry-hl-accent { background: color-mix(in srgb, ${primary} 16%, white); }
|
|
305
|
+
.pc-entry-hl-danger { background: #fee2e2; }
|
|
306
|
+
.pc-entry-text { font-size: 10.5px; color: ${ink}; }
|
|
307
|
+
.pc-entry-meta {
|
|
308
|
+
display: flex;
|
|
309
|
+
justify-content: space-between;
|
|
310
|
+
gap: 8px;
|
|
311
|
+
font-size: 9.5px;
|
|
312
|
+
margin-top: 1px;
|
|
313
|
+
}
|
|
314
|
+
.pc-entry-status { font-weight: 700; color: ${ink}; }
|
|
315
|
+
.pc-entry-aside { color: ${mutedFg}; white-space: nowrap; }
|
|
316
|
+
.pc-entry-sub { font-size: 9px; color: ${mutedFg}; margin-top: 2px; }
|
|
317
|
+
.pc-entry-sub-danger { color: #b91c1c; font-weight: 700; }
|
|
318
|
+
.pc-empty {
|
|
319
|
+
background: ${mutedBg};
|
|
320
|
+
border-radius: 8px;
|
|
321
|
+
padding: 8px 10px;
|
|
322
|
+
font-size: 10px;
|
|
323
|
+
color: ${ink};
|
|
324
|
+
}
|
|
237
325
|
`;
|
|
238
326
|
}
|
|
@@ -127,3 +127,46 @@ describe('documento', () => {
|
|
|
127
127
|
expect(html).toContain('size: A4 landscape');
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('barra e cartões de progresso', () => {
|
|
132
|
+
it('reparte a barra pelo total e mostra a legenda com a porcentagem', () => {
|
|
133
|
+
const html = renderBlock({
|
|
134
|
+
kind: 'segmentBar',
|
|
135
|
+
title: 'Com quem estão',
|
|
136
|
+
segments: [
|
|
137
|
+
{ label: 'Com o aluno', value: 1, tone: 'muted' },
|
|
138
|
+
{ label: 'Aguardando', value: 3, tone: 'accent' },
|
|
139
|
+
{ label: 'Em revisão', value: 0, tone: 'light' },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(html).toContain('seg-muted" style="width:25.00%"');
|
|
144
|
+
expect(html).toContain('seg-accent" style="width:75.00%"');
|
|
145
|
+
expect(html).not.toContain('seg-light" style');
|
|
146
|
+
expect(html).toContain('<strong>3 (75%)</strong>');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('escapa o texto de cada cartão e cai no texto de vazio sem itens', () => {
|
|
150
|
+
const html = renderBlock({
|
|
151
|
+
kind: 'progressCards',
|
|
152
|
+
items: [
|
|
153
|
+
{
|
|
154
|
+
title: '<b>Ana</b>',
|
|
155
|
+
value: '25%',
|
|
156
|
+
segments: [{ value: 1, tone: 'ink' }],
|
|
157
|
+
listTitle: 'Tarefas em aberto (1)',
|
|
158
|
+
entries: [
|
|
159
|
+
{ text: 'Acesso & API', status: 'Aguardando', tone: 'accent', highlight: true, sub: 'Atrasada', subTone: 'danger' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
{ title: 'Bruno', value: '100%', segments: [], entries: [], emptyText: 'Tudo concluído.' },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(html).toContain('<b>Ana</b>');
|
|
167
|
+
expect(html).toContain('Acesso & API');
|
|
168
|
+
expect(html).toContain('pc-entry-hl-accent');
|
|
169
|
+
expect(html).toContain('pc-entry-sub-danger');
|
|
170
|
+
expect(html).toContain('<div class="pc-empty">Tudo concluído.</div>');
|
|
171
|
+
});
|
|
172
|
+
});
|