@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
@@ -2681,6 +2681,11 @@
2681
2681
  "testConnectionSuccess": "Conexão validada com sucesso.",
2682
2682
  "testConnectionOkWithInfo": "Conexão validada: {info}",
2683
2683
  "testConnectionWarning": "Credenciais válidas, mas: {warning}",
2684
+ "testConnectionWarningCodes": {
2685
+ "google_oauth_testing": "A tela de consentimento OAuth está em Teste: os refresh tokens do Google expiram em 7 dias e os canais precisam ser reconectados toda semana. Publique o app (Em produção) para mantê-los conectados.",
2686
+ "youtube_audit_pending": "Enquanto a auditoria da API do YouTube não for aprovada para este projeto do Google Cloud, os vídeos enviados ficam privados.",
2687
+ "google_business_access_pending": "As APIs do Perfil da Empresa exigem acesso aprovado (Basic API Access): até o Google aprovar este projeto, a cota é 0 e as conexões falham."
2688
+ },
2684
2689
  "testConnectionError": "Falha ao testar a conexão.",
2685
2690
  "testConnectionErrorFallback": "Não foi possível validar as credenciais.",
2686
2691
  "testConnectionNotPersisted": "Salve o perfil para registrar o resultado.",
@@ -2726,7 +2731,9 @@
2726
2731
  "fieldWabaId": "ID da Conta do WhatsApp Business (WABA)",
2727
2732
  "fieldVerifyToken": "Verify Token do Webhook",
2728
2733
  "fieldGraphApiVersion": "Versão da Graph API",
2729
- "fieldLinkedInApiVersion": "Versão da API do LinkedIn (AAAAMM)",
2734
+ "fieldLinkedInApiVersion": "Versão da API do LinkedIn (AAAAMM)",
2735
+ "fieldXClientId": "Client ID (OAuth 2.0)",
2736
+ "fieldXClientSecret": "Client Secret (OAuth 2.0)",
2730
2737
  "fieldEnvironmentTest": "Teste",
2731
2738
  "fieldEnvironmentProduction": "Produção",
2732
2739
  "copiedToClipboard": "Copiado para a área de transferência",
@@ -2895,32 +2902,42 @@
2895
2902
  "metaSocialSectionTitle": "Conexão das redes sociais",
2896
2903
  "metaSocialDescription": "Este perfil é o app do HedHog na Meta. As Páginas e contas Instagram são conectadas por marca, em Social → Marcas → Canais.",
2897
2904
  "metaSocialRedirectUriLabel": "URI de redirecionamento do OAuth",
2898
- "metaSocialRedirectUriUnavailable": "Defina NEXT_PUBLIC_API_BASE_URL para exibir a URI",
2905
+ "metaSocialRedirectUriUnavailable": "Preencha a configuração URL da API (api-url) para exibir a URI",
2899
2906
  "metaSocialStepsTitle": "Como configurar no painel da Meta",
2900
2907
  "metaSocialStep1": "Crie um app do tipo Business e adicione o Facebook Login for Business e a API do Instagram com login do Facebook.",
2901
- "metaSocialStep2": "Crie uma Login Configuration do tipo \"User access token\" com as permissões de publicação e cole o ID dela acima.",
2908
+ "metaSocialStep2": "Crie uma Login Configuration do tipo \"User access token\" com as permissões pages_show_list, business_management, pages_read_engagement, pages_manage_posts, instagram_basic e instagram_content_publish, e cole o ID dela no campo Login Configuration ID.",
2902
2909
  "metaSocialStep3": "Cadastre a URI acima em \"Valid OAuth Redirect URIs\".",
2903
- "metaSocialStep4": "Até o App Review ser aprovado, use o modo Sandbox ou só contas de administradores e testadores do app.",
2910
+ "metaSocialStep4": "Em Configurações do app > Básico, preencha a política de privacidade, os termos de uso e a URL das instruções de exclusão de dados.",
2904
2911
  "instagramSocialSectionTitle": "Conexão com o Instagram",
2905
2912
  "instagramSocialDescription": "Este perfil é o app do HedHog na API do Instagram com login do Instagram. As contas profissionais do Instagram (Empresa ou Criador de conteúdo) são conectadas por marca, em Social → Marcas → Canais, com ou sem Página do Facebook.",
2906
2913
  "instagramSocialRedirectUriLabel": "URI de redirecionamento do OAuth",
2907
- "instagramSocialRedirectUriUnavailable": "Defina NEXT_PUBLIC_API_BASE_URL para exibir a URI",
2914
+ "instagramSocialRedirectUriUnavailable": "Preencha a configuração URL da API (api-url) para exibir a URI",
2908
2915
  "instagramSocialStepsTitle": "Como configurar no painel da Meta",
2909
2916
  "instagramSocialStep1": "No app da Meta, adicione o produto Instagram e abra \"API setup with Instagram login\".",
2910
2917
  "instagramSocialStep2": "Copie o ID do app do Instagram e a chave secreta do app do Instagram exibidos ali e cole acima. Eles não são o ID e a chave do app da Meta.",
2911
2918
  "instagramSocialStep3": "Em \"Set up Instagram business login\" > Business login settings, cadastre a URI acima em \"OAuth redirect URIs\" exatamente como aparece, sem barra no final.",
2912
2919
  "instagramSocialStep4": "Nas mesmas configurações, preencha a Deauthorize callback URL e a Data deletion request URL com a página das suas instruções de privacidade.",
2913
- "instagramSocialStep5": "Até o App Review ser aprovado, adicione as contas que serão conectadas como testadoras do Instagram no app, ou use o modo Sandbox.",
2914
- "linkedinSocialSectionTitle": "Conexão com o LinkedIn",
2915
- "linkedinSocialDescription": "Este perfil é o app do HedHog na Community Management API do LinkedIn. As páginas de empresa são conectadas por marca, em Social → Marcas → Canais, por um membro que seja administrador ou administrador de conteúdo da página.",
2916
- "linkedinSocialRedirectUriLabel": "URL de redirecionamento autorizada",
2917
- "linkedinSocialRedirectUriUnavailable": "Defina NEXT_PUBLIC_API_BASE_URL para exibir a URL",
2918
- "linkedinSocialStepsTitle": "Como configurar no LinkedIn Developer Portal",
2919
- "linkedinSocialStep1": "Crie um app dedicado, sem outros produtos, vinculado à página da empresa e verificado por um superadministrador dessa Página do LinkedIn.",
2920
- "linkedinSocialStep2": "Na aba Products, solicite a Community Management API. O acesso começa no nível Development.",
2921
- "linkedinSocialStep3": "Na aba Auth, cadastre a URL acima em \"Authorized redirect URLs for your app\" exatamente como aparece e copie o Client ID e o Primary Client Secret para os campos acima.",
2922
- "linkedinSocialStep4": "Antes de ir para produção, solicite o nível Standard, que exige um screencast de cada caso de uso do app.",
2923
- "linkedinSocialStep5": "Até o nível Standard ser aprovado, use o modo Sandbox.",
2920
+ "instagramSocialStep5": "Até o App Review ser aprovado, adicione as contas que serão conectadas como testadoras do Instagram no app e mantenha o modo Production: Sandbox é um simulador local e é recusado em produção.",
2921
+ "linkedinSocialSectionTitle": "Conexão com o LinkedIn",
2922
+ "linkedinSocialDescription": "Este perfil é o app do HedHog na Community Management API do LinkedIn. As páginas de empresa são conectadas por marca, em Social → Marcas → Canais, por um membro que seja administrador ou administrador de conteúdo da página.",
2923
+ "linkedinSocialRedirectUriLabel": "URL de redirecionamento autorizada",
2924
+ "linkedinSocialRedirectUriUnavailable": "Preencha a configuração URL da API (api-url) para exibir a URL",
2925
+ "linkedinSocialStepsTitle": "Como configurar no LinkedIn Developer Portal",
2926
+ "linkedinSocialStep1": "Crie um app dedicado, sem outros produtos, vinculado à página da empresa e verificado por um superadministrador dessa Página do LinkedIn.",
2927
+ "linkedinSocialStep2": "Na aba Products, solicite a Community Management API. O acesso começa no nível Development.",
2928
+ "linkedinSocialStep3": "Na aba Auth, cadastre a URL acima em \"Authorized redirect URLs for your app\" exatamente como aparece e copie o Client ID e o Primary Client Secret para os campos acima.",
2929
+ "linkedinSocialStep4": "Antes de ir para produção, solicite o nível Standard, que exige um screencast de cada caso de uso do app.",
2930
+ "linkedinSocialStep5": "Use o modo Production: ele já chama o LinkedIn no nível Development, dentro dos limites dele. Sandbox é um simulador local e é recusado em produção.",
2931
+ "xSocialSectionTitle": "Conexão com o X",
2932
+ "xSocialSectionDescription": "Este perfil é o app do HedHog na API do X, paga por uso. As contas do X são conectadas por marca, em Social → Marcas → Canais, por quem entra no X com a conta da marca.",
2933
+ "xSocialRedirectUriLabel": "Callback URI",
2934
+ "xSocialRedirectUriUnavailable": "Preencha a configuração URL da API (api-url) para exibir a URL",
2935
+ "xSocialStepsTitle": "Como configurar no X Developer Console",
2936
+ "xSocialStep1": "No Developer Console, crie um Project e um App. Em User authentication settings, ative o OAuth 2.0 com o tipo Web App (confidential client) e as permissões Read and write.",
2937
+ "xSocialStep2": "Cadastre a URL acima como Callback URI, exatamente como aparece, em cada ambiente.",
2938
+ "xSocialStep3": "Copie o Client ID e o Client Secret do OAuth 2.0 para os campos acima (não são a API Key nem a API Secret).",
2939
+ "xSocialStep4": "Em Billing, compre créditos e defina um teto de gasto: a API é paga por uso (US$ 0,015 por post, US$ 0,20 com link).",
2940
+ "xSocialStep5": "Use o modo Production para conectar contas reais. Sandbox é um simulador local e é recusado em produção.",
2924
2941
  "fieldOauthAppStatus": "Status da tela de consentimento OAuth",
2925
2942
  "fieldOauthAppStatusTesting": "Em teste (o token de atualização expira em 7 dias e o canal precisa ser reconectado)",
2926
2943
  "fieldOauthAppStatusProduction": "Em produção (o token de atualização não expira em 7 dias)",
@@ -2928,10 +2945,10 @@
2928
2945
  "youtubeSocialDescription": "Este perfil é o projeto do HedHog no Google Cloud para o YouTube. Os canais são conectados por marca, em Social → Marcas → Canais.",
2929
2946
  "googleBusinessSocialDescription": "Este perfil é o projeto do HedHog no Google Cloud para o Perfil da Empresa no Google. Os locais são conectados por marca, em Social → Marcas → Canais.",
2930
2947
  "googleSocialRedirectUriLabel": "URI de redirecionamento autorizado do OAuth",
2931
- "googleSocialRedirectUriUnavailable": "Defina NEXT_PUBLIC_API_BASE_URL para exibir a URI",
2948
+ "googleSocialRedirectUriUnavailable": "Preencha a configuração URL da API (api-url) para exibir a URI",
2932
2949
  "googleSocialStepsTitle": "Como configurar no Google Cloud Console",
2933
2950
  "youtubeSocialStep1": "Crie um projeto no Google Cloud só para o YouTube e ative a YouTube Data API v3.",
2934
- "youtubeSocialStep2": "Configure a tela de consentimento OAuth com os escopos youtube.upload e youtube.readonly. Por serem escopos sensíveis, o app precisa passar pela verificação do Google antes de ser publicado.",
2951
+ "youtubeSocialStep2": "Configure a tela de consentimento OAuth com os escopos youtube.upload, youtube.readonly e youtube.force-ssl (sensíveis, exigem a verificação do Google antes de o app ser publicado) e yt-analytics.readonly (não sensível).",
2935
2952
  "youtubeSocialStep3": "Crie um ID do cliente OAuth do tipo \"Aplicativo da Web\", cadastre a URI acima em \"URIs de redirecionamento autorizados\" e cole o Client ID e o Client Secret acima.",
2936
2953
  "youtubeSocialStep4": "Solicite a auditoria de conformidade da API do YouTube: até ela ser aprovada, todo vídeo enviado pelo projeto fica privado.",
2937
2954
  "youtubeSocialStep5": "A cota padrão do projeto permite cerca de 100 envios de vídeo por dia. Ao esgotar, a publicação é reagendada para depois do reset (meia-noite no horário do Pacífico).",
@@ -2940,7 +2957,10 @@
2940
2957
  "googleBusinessSocialStep3": "Ative a My Business Account Management API, a My Business Business Information API e a Google My Business API. Até o acesso ser aprovado a cota é 0 e toda chamada falha.",
2941
2958
  "googleBusinessSocialStep4": "Configure a tela de consentimento OAuth com o escopo business.manage.",
2942
2959
  "googleBusinessSocialStep5": "Crie um ID do cliente OAuth do tipo \"Aplicativo da Web\", cadastre a URI acima em \"URIs de redirecionamento autorizados\" e cole o Client ID e o Client Secret acima.",
2943
- "googleSocialTestingStep": "Enquanto a tela de consentimento estiver em \"Teste\", escolha \"Em teste\" no status acima e use o modo Sandbox para desenvolver sem depender das aprovações do Google."
2960
+ "googleSocialTestingStep": "Enquanto a tela de consentimento estiver em \"Teste\", escolha \"Em teste\" no status acima e adicione como usuários de teste as contas Google que serão conectadas.",
2961
+ "metaSocialStep5": "Conclua a Verificação da empresa e peça a App Review das permissões. Até a aprovação, só administradores, desenvolvedores e testadores do app conseguem conectar.",
2962
+ "socialRedirectUriMismatch": "A configuração URL da API ({setting}) é diferente da API que este painel usa ({env}). A URI acima segue a configuração, que é o que o backend envia à rede.",
2963
+ "socialGuideLink": "Ver o guia completo de configuração"
2944
2964
  },
2945
2965
  "SystemPagesPage": {
2946
2966
  "title": "Páginas",
@@ -3356,7 +3376,13 @@
3356
3376
  "redeemInvalidDescription": "O link funciona uma única vez e expira em um minuto. Peça ao operador para iniciar uma nova simulação.",
3357
3377
  "redeemMissingCode": "Nenhum código de simulação foi encontrado no link.",
3358
3378
  "noAdminAccessTitle": "Este usuário não tem acesso ao painel",
3359
- "noAdminAccessDescription": "{name} não tem papel administrativo, então não há o que mostrar aqui. Simule esse acesso na aplicação que a pessoa realmente usa."
3379
+ "noAdminAccessDescription": "{name} não tem papel administrativo, então não há o que mostrar aqui. Simule esse acesso na aplicação que a pessoa realmente usa.",
3380
+ "apps": {
3381
+ "admin": "Hub (administração)",
3382
+ "class": "Hcode Class",
3383
+ "partners": "Portal de Parceiros",
3384
+ "training": "Hcode Training"
3385
+ }
3360
3386
  },
3361
3387
  "MessageTemplatesPage": {
3362
3388
  "title": "Modelos de mensagem",
@@ -15,8 +15,10 @@ columns:
15
15
  table: file_mimetype
16
16
  column: id
17
17
  onDelete: CASCADE
18
+ # Em bytes. bigint: int parava em 2.147.483.647 (~2 GB) e video maior nao
19
+ # entrava. O PrismaService devolve o valor como number (extension de result).
18
20
  - name: size
19
- type: int
21
+ type: bigint
20
22
  unsigned: true
21
23
  default: 0
22
24
  # Tira o arquivo das duas rotas que servem por id sem autenticacao
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/core",
3
- "version": "0.0.401",
3
+ "version": "0.0.402",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -48,12 +48,12 @@
48
48
  "ua-parser-js": "^2.0.10",
49
49
  "uuid": "^11.1.0",
50
50
  "@hed-hog/api-prisma": "0.0.7",
51
+ "@hed-hog/vault-crypto": "0.0.1",
51
52
  "@hed-hog/api-mail": "0.0.11",
52
53
  "@hed-hog/api-locale": "0.0.15",
53
- "@hed-hog/vault-crypto": "0.0.1",
54
54
  "@hed-hog/api-types": "0.0.2",
55
- "@hed-hog/api-pagination": "0.0.8",
56
- "@hed-hog/api": "0.0.9"
55
+ "@hed-hog/api": "0.0.9",
56
+ "@hed-hog/api-pagination": "0.0.8"
57
57
  },
58
58
  "exports": {
59
59
  ".": {
@@ -0,0 +1,50 @@
1
+ import 'reflect-metadata';
2
+ import { SetMetadata } from '@nestjs/common';
3
+ import { lastValueFrom, of } from 'rxjs';
4
+ import { AccessLogInterceptor, SKIP_ACCESS_LOG_KEY } from './access-log.interceptor';
5
+
6
+ class PlainController {
7
+ handler() {}
8
+ }
9
+
10
+ @SetMetadata(SKIP_ACCESS_LOG_KEY, true)
11
+ class AnonymousController {
12
+ handler() {}
13
+ }
14
+
15
+ function contextFor(controller: any) {
16
+ const request = {
17
+ url: '/x',
18
+ ip: '203.0.113.7',
19
+ method: 'POST',
20
+ headers: { 'user-agent': 'UA' },
21
+ };
22
+ return {
23
+ getType: () => 'http',
24
+ switchToHttp: () => ({ getRequest: () => request }),
25
+ getHandler: () => controller.prototype.handler,
26
+ getClass: () => controller,
27
+ } as any;
28
+ }
29
+
30
+ describe('AccessLogInterceptor', () => {
31
+ it('records ordinary requests', async () => {
32
+ const recordHttp = jest.fn();
33
+ const interceptor = new AccessLogInterceptor({ recordHttp } as any);
34
+ await lastValueFrom(
35
+ interceptor.intercept(contextFor(PlainController), { handle: () => of(1) }),
36
+ );
37
+ expect(recordHttp).toHaveBeenCalledWith(
38
+ expect.objectContaining({ ip: '203.0.113.7', userAgent: 'UA' }),
39
+ );
40
+ });
41
+
42
+ it('skips routes marked with skipAccessLog (anonymous whistleblower channel)', async () => {
43
+ const recordHttp = jest.fn();
44
+ const interceptor = new AccessLogInterceptor({ recordHttp } as any);
45
+ await lastValueFrom(
46
+ interceptor.intercept(contextFor(AnonymousController), { handle: () => of(1) }),
47
+ );
48
+ expect(recordHttp).not.toHaveBeenCalled();
49
+ });
50
+ });
@@ -7,6 +7,17 @@ import {
7
7
  import { Observable, tap } from 'rxjs';
8
8
  import { AccessLogService } from './access-log.service';
9
9
 
10
+ /**
11
+ * Metadado (handler ou classe) que tira a rota do access log.
12
+ *
13
+ * Existe para superficies cujo valor depende de NAO saber quem chamou — o canal
14
+ * de denuncias da library integrity: uma linha com IP, user agent e horario de
15
+ * `POST /integrity/public/reports` identificaria o denunciante. Quem usa aplica
16
+ * `SetMetadata('skipAccessLog', true)` direto, sem importar daqui, para uma
17
+ * library nao exigir esta versao do core (um core antigo apenas continua logando).
18
+ */
19
+ export const SKIP_ACCESS_LOG_KEY = 'skipAccessLog';
20
+
10
21
  @Injectable()
11
22
  export class AccessLogInterceptor implements NestInterceptor {
12
23
  constructor(private readonly accessLogService: AccessLogService) {}
@@ -23,6 +34,13 @@ export class AccessLogInterceptor implements NestInterceptor {
23
34
  return next.handle();
24
35
  }
25
36
 
37
+ if (
38
+ Reflect.getMetadata(SKIP_ACCESS_LOG_KEY, context.getHandler()) === true ||
39
+ Reflect.getMetadata(SKIP_ACCESS_LOG_KEY, context.getClass()) === true
40
+ ) {
41
+ return next.handle();
42
+ }
43
+
26
44
  return next.handle().pipe(
27
45
  tap(() => {
28
46
  const userId: number | undefined = request.auth?.sub;
@@ -0,0 +1,344 @@
1
+ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2
+ import { BadRequestException, ConflictException } from '@nestjs/common';
3
+ import { JwtService } from '@nestjs/jwt';
4
+ import { FileService } from './file.service';
5
+
6
+ /**
7
+ * Upload direto em partes assinadas: o cliente manda os bytes ao storage e a API so
8
+ * abre, assina e fecha. O que estes testes protegem:
9
+ *
10
+ * - a chave segue o `location`/`path` que o `uploadFromPath` grava;
11
+ * - a regra de parte do Studio (>= 16 MiB, <= 9000 partes);
12
+ * - no complete, nada vira linha `file` sem conferir tamanho, teto, allowlist e os
13
+ * primeiros bytes — e o que for recusado sai do bucket;
14
+ * - o token só é aceito com a assinatura e o tipo certos.
15
+ */
16
+ const MIB = 1024 * 1024;
17
+
18
+ describe('FileService — upload direto', () => {
19
+ let deps: any;
20
+ let service: FileService;
21
+ let provider: any;
22
+ let settingValues: Record<string, unknown>;
23
+
24
+ beforeEach(() => {
25
+ process.env.JWT_SECRET = 'spec-secret';
26
+ settingValues = {
27
+ 'storage-max-size': String(1024 * MIB),
28
+ 'storage-accept-mimetype': ['video/mp4', 'image/jpeg'],
29
+ };
30
+
31
+ deps = {
32
+ prisma: {
33
+ file: {
34
+ create: jest.fn().mockImplementation((args: any) => Promise.resolve({ id: 42, ...args.data })),
35
+ },
36
+ },
37
+ pagination: {},
38
+ setting: {
39
+ getSettingValues: jest.fn().mockImplementation(async (slugs: any) =>
40
+ Object.fromEntries((slugs as string[]).map((slug) => [slug, settingValues[slug]])),
41
+ ),
42
+ },
43
+ jwt: new JwtService(),
44
+ imageOptimization: {},
45
+ };
46
+
47
+ service = new FileService(
48
+ deps.prisma,
49
+ deps.pagination,
50
+ deps.setting,
51
+ deps.jwt,
52
+ deps.imageOptimization,
53
+ );
54
+
55
+ provider = {
56
+ getFilename: jest.fn().mockReturnValue('uuid-1.mp4'),
57
+ createMultipartUpload: jest.fn().mockResolvedValue('upload-1' as never),
58
+ signUploadParts: jest.fn().mockImplementation(async (_k: any, _u: any, numbers: any) =>
59
+ (numbers as number[]).map((partNumber) => ({ partNumber, url: `https://put/${partNumber}` })),
60
+ ),
61
+ completeMultipartUpload: jest.fn().mockResolvedValue(undefined as never),
62
+ abortMultipartUpload: jest.fn().mockResolvedValue(undefined as never),
63
+ readHead: jest.fn().mockResolvedValue(Buffer.from('head') as never),
64
+ metaData: jest.fn().mockResolvedValue({ ContentLength: 100 * MIB } as never),
65
+ delete: jest.fn().mockResolvedValue(true as never),
66
+ };
67
+
68
+ jest.spyOn(service, 'getProvider').mockImplementation(async () => {
69
+ (service as any).providerId = 3;
70
+ return provider;
71
+ });
72
+ jest.spyOn(service, 'getMimeType').mockResolvedValue(7 as never);
73
+ });
74
+
75
+ afterEach(() => jest.restoreAllMocks());
76
+
77
+ const complete = (overrides: Record<string, unknown> = {}) =>
78
+ service.completeDirectUpload({
79
+ key: 'social/brands/1/uuid-1.mp4',
80
+ uploadId: 'upload-1',
81
+ destination: 'social/brands/1',
82
+ parts: [{ partNumber: 1, etag: '"a"' }],
83
+ size: 100 * MIB,
84
+ mimetype: 'video/mp4',
85
+ originalname: 'demo.mp4',
86
+ isPrivate: true,
87
+ createdByUserId: 9,
88
+ ...overrides,
89
+ } as any);
90
+
91
+ describe('supportsDirectUpload', () => {
92
+ it('provider com os cinco métodos suporta', async () => {
93
+ await expect(service.supportsDirectUpload()).resolves.toBe(true);
94
+ });
95
+
96
+ it('provider sem multipart (local, gcs, azure) não suporta', async () => {
97
+ delete provider.readHead;
98
+ await expect(service.supportsDirectUpload()).resolves.toBe(false);
99
+ });
100
+
101
+ it('sem storage configurado é false, não erro', async () => {
102
+ (service.getProvider as any).mockRejectedValueOnce(new Error('no profile'));
103
+ await expect(service.supportsDirectUpload()).resolves.toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('startDirectUpload', () => {
108
+ it('abre o multipart com a chave destination/uuid.ext e a regra de partes', async () => {
109
+ const result = await service.startDirectUpload('social/brands/1', {
110
+ originalname: 'demo.mp4',
111
+ mimetype: 'video/mp4',
112
+ size: 216_000_000,
113
+ });
114
+
115
+ expect(provider.getFilename).toHaveBeenCalledWith('demo.mp4');
116
+ expect(provider.createMultipartUpload).toHaveBeenCalledWith(
117
+ 'social/brands/1/uuid-1.mp4',
118
+ 'video/mp4',
119
+ );
120
+ expect(result).toEqual({
121
+ key: 'social/brands/1/uuid-1.mp4',
122
+ uploadId: 'upload-1',
123
+ partSize: 16 * MIB,
124
+ partCount: 13,
125
+ });
126
+ });
127
+
128
+ it('arquivo enorme: a parte cresce para caber em 9000 partes', async () => {
129
+ settingValues['storage-max-size'] = String(2_147_483_647);
130
+ const size = 2_000_000_000;
131
+ const { partSize, partCount } = await service.startDirectUpload('x', {
132
+ originalname: 'a.mp4',
133
+ mimetype: 'video/mp4',
134
+ size,
135
+ });
136
+ // 2 GB / 16 MiB = 120 partes: ainda vale o mínimo.
137
+ expect(partSize).toBe(16 * MIB);
138
+ expect(partCount).toBe(Math.ceil(size / (16 * MIB)));
139
+ });
140
+
141
+ it('acima da storage-max-size é recusado antes de abrir nada', async () => {
142
+ settingValues['storage-max-size'] = String(10 * MIB);
143
+ await expect(
144
+ service.startDirectUpload('x', { originalname: 'a.mp4', mimetype: 'video/mp4', size: 11 * MIB }),
145
+ ).rejects.toThrow('File too large');
146
+ expect(provider.createMultipartUpload).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('skipSizeValidation pula a storage-max-size, mas não o teto de file.size', async () => {
150
+ settingValues['storage-max-size'] = String(10 * MIB);
151
+ const info = { originalname: 'a.mp4', mimetype: 'video/mp4', skipSizeValidation: true };
152
+
153
+ await expect(service.startDirectUpload('x', { ...info, size: 11 * MIB })).resolves.toMatchObject({
154
+ uploadId: 'upload-1',
155
+ });
156
+ await expect(
157
+ service.startDirectUpload('x', { ...info, size: Number.MAX_SAFE_INTEGER + 1 }),
158
+ ).rejects.toThrow('File too large');
159
+ });
160
+
161
+ it('fora da allowlist é recusado, a não ser com skipMimeValidation', async () => {
162
+ await expect(
163
+ service.startDirectUpload('x', { originalname: 'a.mov', mimetype: 'video/quicktime', size: MIB }),
164
+ ).rejects.toThrow('Invalid file type');
165
+
166
+ await expect(
167
+ service.startDirectUpload('x', {
168
+ originalname: 'a.mov',
169
+ mimetype: 'video/quicktime',
170
+ size: MIB,
171
+ skipMimeValidation: true,
172
+ }),
173
+ ).resolves.toMatchObject({ uploadId: 'upload-1' });
174
+ });
175
+
176
+ it('acima de 2 GB passa (file.size é BIGINT); acima do number exato é recusado', async () => {
177
+ settingValues['storage-max-size'] = String(10 * 1024 * MIB);
178
+ const info = { originalname: 'a.mp4', mimetype: 'video/mp4' };
179
+
180
+ await expect(service.startDirectUpload('x', { ...info, size: 3 * 1024 * MIB })).resolves.toMatchObject({
181
+ uploadId: 'upload-1',
182
+ });
183
+ await expect(
184
+ service.startDirectUpload('x', { ...info, size: Number.MAX_SAFE_INTEGER + 1, skipSizeValidation: true }),
185
+ ).rejects.toThrow('File too large');
186
+ });
187
+
188
+ it('storage sem upload direto é 409', async () => {
189
+ delete provider.createMultipartUpload;
190
+ await expect(
191
+ service.startDirectUpload('x', { originalname: 'a.mp4', mimetype: 'video/mp4', size: MIB }),
192
+ ).rejects.toBeInstanceOf(ConflictException);
193
+ });
194
+ });
195
+
196
+ describe('signDirectUploadParts / abortDirectUpload', () => {
197
+ it('assina as partes pedidas, sem repetir', async () => {
198
+ const parts = await service.signDirectUploadParts('k', 'u', [1, 2, 2], 3600);
199
+ expect(provider.signUploadParts).toHaveBeenCalledWith('k', 'u', [1, 2], 3600);
200
+ expect(parts).toHaveLength(2);
201
+ });
202
+
203
+ it('número de parte inválido é 400', async () => {
204
+ await expect(service.signDirectUploadParts('k', 'u', [0])).rejects.toBeInstanceOf(
205
+ BadRequestException,
206
+ );
207
+ await expect(service.signDirectUploadParts('k', 'u', [9001])).rejects.toBeInstanceOf(
208
+ BadRequestException,
209
+ );
210
+ });
211
+
212
+ it('abort de upload que já não existe não é erro', async () => {
213
+ provider.abortMultipartUpload.mockRejectedValueOnce(
214
+ Object.assign(new Error('gone'), { name: 'NoSuchUpload' }),
215
+ );
216
+ await expect(service.abortDirectUpload('k', 'u')).resolves.toBeUndefined();
217
+ });
218
+ });
219
+
220
+ describe('completeDirectUpload', () => {
221
+ it('fecha, confere e grava a linha file como o uploadFromPath', async () => {
222
+ const file: any = await complete();
223
+
224
+ expect(provider.completeMultipartUpload).toHaveBeenCalledWith(
225
+ 'social/brands/1/uuid-1.mp4',
226
+ 'upload-1',
227
+ [{ partNumber: 1, etag: '"a"' }],
228
+ );
229
+ expect(deps.prisma.file.create).toHaveBeenCalledWith({
230
+ data: {
231
+ filename: 'demo.mp4',
232
+ path: 'uuid-1.mp4',
233
+ provider_id: 3,
234
+ location: 'social/brands/1',
235
+ mimetype_id: 7,
236
+ size: 100 * MIB,
237
+ is_private: true,
238
+ created_by_user_id: 9,
239
+ },
240
+ });
241
+ expect(file.id).toBe(42);
242
+ expect(provider.delete).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it('tamanho gravado diferente do declarado: 400 e objeto apagado', async () => {
246
+ provider.metaData.mockResolvedValueOnce({ ContentLength: 5 });
247
+ await expect(complete()).rejects.toThrow('differs from the declared size');
248
+ expect(provider.delete).toHaveBeenCalledWith('social/brands/1/uuid-1.mp4');
249
+ expect(deps.prisma.file.create).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('acima da storage-max-size: 400 e objeto apagado', async () => {
253
+ settingValues['storage-max-size'] = String(10 * MIB);
254
+ await expect(complete()).rejects.toThrow('File too large');
255
+ expect(provider.delete).toHaveBeenCalled();
256
+ expect(deps.prisma.file.create).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it('acima da storage-max-size com skipSizeValidation: grava a linha', async () => {
260
+ settingValues['storage-max-size'] = String(10 * MIB);
261
+ await complete({ skipSizeValidation: true });
262
+ expect(provider.delete).not.toHaveBeenCalled();
263
+ expect(deps.prisma.file.create).toHaveBeenCalled();
264
+ });
265
+
266
+ it('fora da allowlist: 400 e objeto apagado', async () => {
267
+ await expect(complete({ mimetype: 'application/zip' })).rejects.toThrow('Invalid file type');
268
+ expect(provider.delete).toHaveBeenCalled();
269
+ });
270
+
271
+ it('verifyHead recusa: objeto apagado e o erro sobe como veio', async () => {
272
+ const check = jest.fn(() => {
273
+ throw new BadRequestException('not a video');
274
+ });
275
+ await expect(complete({ verifyHead: { bytes: 32, check } })).rejects.toThrow('not a video');
276
+ expect(provider.readHead).toHaveBeenCalledWith('social/brands/1/uuid-1.mp4', 32);
277
+ expect(provider.delete).toHaveBeenCalled();
278
+ expect(deps.prisma.file.create).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it('verifyHead que devolve o tipo real: é ele que vai para a linha', async () => {
282
+ const getMimeType = service.getMimeType as any;
283
+ await complete({
284
+ mimetype: 'video/mp4',
285
+ skipMimeValidation: true,
286
+ verifyHead: { bytes: 32, check: () => 'video/quicktime' },
287
+ });
288
+ expect(getMimeType).toHaveBeenCalledWith('video/quicktime');
289
+ });
290
+
291
+ it('partes que o storage não monta (4xx): aborta e responde 400', async () => {
292
+ provider.completeMultipartUpload.mockRejectedValueOnce(
293
+ Object.assign(new Error('InvalidPart'), { $metadata: { httpStatusCode: 400 } }),
294
+ );
295
+ await expect(complete()).rejects.toThrow('could not be assembled');
296
+ expect(provider.abortMultipartUpload).toHaveBeenCalledWith(
297
+ 'social/brands/1/uuid-1.mp4',
298
+ 'upload-1',
299
+ );
300
+ });
301
+
302
+ it('chave fora do destination é 400, sem tocar no storage', async () => {
303
+ await expect(complete({ key: 'outra/pasta/uuid-1.mp4' })).rejects.toThrow(
304
+ 'Invalid direct upload key',
305
+ );
306
+ await expect(complete({ key: 'social/brands/1/sub/uuid-1.mp4' })).rejects.toThrow(
307
+ 'Invalid direct upload key',
308
+ );
309
+ expect(provider.completeMultipartUpload).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it('sem partes é 400', async () => {
313
+ await expect(complete({ parts: [] })).rejects.toThrow('Invalid upload parts');
314
+ });
315
+ });
316
+
317
+ describe('token', () => {
318
+ it('ida e volta com as claims', () => {
319
+ const token = service.signDirectUploadToken({ key: 'k', brandId: 1 }, 60);
320
+ expect(service.readDirectUploadToken(token)).toMatchObject({ key: 'k', brandId: 1 });
321
+ });
322
+
323
+ it('claims adulteradas (sem reassinar) são 400', () => {
324
+ const token = service.signDirectUploadToken({ key: 'k', size: 10 }, 60);
325
+ const [header, payload, signature] = token.split('.');
326
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
327
+ const forged = Buffer.from(JSON.stringify({ ...claims, size: 1 })).toString('base64url');
328
+
329
+ expect(() => service.readDirectUploadToken(`${header}.${forged}.${signature}`)).toThrow(
330
+ 'Invalid or expired upload token',
331
+ );
332
+ });
333
+
334
+ it('assinado com outro segredo é 400', () => {
335
+ const token = new JwtService().sign({ t: 'file-direct-upload' }, { secret: 'other' });
336
+ expect(() => service.readDirectUploadToken(token)).toThrow('Invalid or expired upload token');
337
+ });
338
+
339
+ it('JWT válido de outro tipo (ex.: download) é 400', () => {
340
+ const token = service.createDownloadToken('a/b.mp4', 60);
341
+ expect(() => service.readDirectUploadToken(token)).toThrow('not a direct upload token');
342
+ });
343
+ });
344
+ });