@omnizap-system/omnizap 2.6.2 → 2.6.3
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/.env.example +24 -0
- package/app/config/index.js +4 -0
- package/app/configParts/adminIdentity.js +29 -0
- package/app/configParts/baileysConfig.js +116 -0
- package/app/configParts/groupUtils.js +221 -0
- package/app/configParts/loggerConfig.js +185 -0
- package/app/configParts/messagePersistenceService.js +169 -7
- package/app/configParts/sessionConfig.js +85 -0
- package/app/connection/baileysCompatibility.test.js +9 -0
- package/app/connection/baileysDbAuthState.js +205 -9
- package/app/connection/baileysLibsignalPatch.js +210 -0
- package/app/connection/groupOwnerWriteStateResolver.js +53 -21
- package/app/connection/socketController.js +95 -25
- package/app/connection/socketController.multiSession.test.js +20 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +17 -3
- package/app/controllers/messageProcessingPipeline.js +2 -0
- package/app/controllers/messageProcessingPipeline.test.js +15 -13
- package/app/services/multiSession/assignmentBalancerService.js +1 -6
- package/app/services/multiSession/groupOwnershipRepository.js +9 -44
- package/app/services/multiSession/groupOwnershipService.js +9 -90
- package/app/services/multiSession/groupOwnershipService.test.js +12 -4
- package/app/services/multiSession/sessionRegistryService.js +6 -60
- package/app/utils/antiLink/antiLinkModule.js +54 -24
- package/docs/security/omnizap-static-security-headers.conf +3 -3
- package/package.json +3 -2
- package/public/comandos/commands-catalog.json +1 -1
- package/public/css/payments-react.css +478 -0
- package/public/js/apps/homeReactApp.js +2 -2
- package/public/js/apps/paymentsCancelReactApp.js +45 -0
- package/public/js/apps/paymentsReactApp.js +399 -0
- package/public/js/apps/paymentsSuccessReactApp.js +148 -0
- package/public/pages/pagamentos-cancelado.html +21 -0
- package/public/pages/pagamentos-sucesso.html +21 -0
- package/public/pages/pagamentos.html +30 -0
- package/scripts/deploy.sh +3 -0
- package/scripts/new-whatsapp-session.sh +247 -0
- package/server/controllers/admin/systemAdminController.js +4 -17
- package/server/controllers/payments/paymentsController.js +731 -0
- package/server/controllers/system/systemController.js +4 -30
- package/server/email/emailAutomationRuntime.js +36 -1
- package/server/email/emailAutomationService.js +42 -1
- package/server/email/emailTemplateService.js +137 -31
- package/server/http/httpRequestUtils.js +18 -14
- package/server/middleware/securityHeaders.js +15 -2
- package/server/routes/indexRouter.js +27 -7
- package/server/routes/payments/paymentsRouter.js +47 -0
- package/server/routes/static/staticPageRouter.js +3 -0
- package/vite.config.mjs +3 -0
|
@@ -190,9 +190,7 @@ export const listSystemAdminSessions = async ({ status = null, limit = 200 } = {
|
|
|
190
190
|
if (row?.sessionId) knownSessionIds.add(row.sessionId);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
const selectedSessionIds = Array.from(knownSessionIds)
|
|
194
|
-
.filter(Boolean)
|
|
195
|
-
.slice(0, safeLimit);
|
|
193
|
+
const selectedSessionIds = Array.from(knownSessionIds).filter(Boolean).slice(0, safeLimit);
|
|
196
194
|
const registryBySession = new Map((registryRows || []).map((row) => [row.sessionId, row]));
|
|
197
195
|
|
|
198
196
|
const sessions = selectedSessionIds.map((sessionId) => {
|
|
@@ -226,14 +224,7 @@ export const listSystemAdminSessions = async ({ status = null, limit = 200 } = {
|
|
|
226
224
|
};
|
|
227
225
|
};
|
|
228
226
|
|
|
229
|
-
export const listSystemAdminAssignments = async (
|
|
230
|
-
{
|
|
231
|
-
groupJid = null,
|
|
232
|
-
ownerSessionId = null,
|
|
233
|
-
includeExpired = false,
|
|
234
|
-
limit = 200,
|
|
235
|
-
} = {},
|
|
236
|
-
) => {
|
|
227
|
+
export const listSystemAdminAssignments = async ({ groupJid = null, ownerSessionId = null, includeExpired = false, limit = 200 } = {}) => {
|
|
237
228
|
const safeGroupJid = normalizeOptional(groupJid, 255);
|
|
238
229
|
const safeOwnerSessionId = normalizeOptional(ownerSessionId, 64);
|
|
239
230
|
const safeIncludeExpired = normalizeBoolean(includeExpired, false);
|
|
@@ -269,16 +260,7 @@ export const listSystemAdminAssignments = async (
|
|
|
269
260
|
};
|
|
270
261
|
};
|
|
271
262
|
|
|
272
|
-
export const setSystemAdminGroupPin = async (
|
|
273
|
-
{
|
|
274
|
-
groupJid,
|
|
275
|
-
pinned,
|
|
276
|
-
sessionId = null,
|
|
277
|
-
reason = null,
|
|
278
|
-
changedBy = 'admin_api',
|
|
279
|
-
metadata = null,
|
|
280
|
-
} = {},
|
|
281
|
-
) => {
|
|
263
|
+
export const setSystemAdminGroupPin = async ({ groupJid, pinned, sessionId = null, reason = null, changedBy = 'admin_api', metadata = null } = {}) => {
|
|
282
264
|
const outcome = await groupOwnershipService.setPinned({
|
|
283
265
|
groupJid,
|
|
284
266
|
pinned,
|
|
@@ -307,15 +289,7 @@ export const setSystemAdminGroupPin = async (
|
|
|
307
289
|
};
|
|
308
290
|
};
|
|
309
291
|
|
|
310
|
-
export const forceSystemAdminGroupFailover = async (
|
|
311
|
-
{
|
|
312
|
-
groupJid,
|
|
313
|
-
targetSessionId,
|
|
314
|
-
reason = 'admin_force_failover',
|
|
315
|
-
changedBy = 'admin_api',
|
|
316
|
-
metadata = null,
|
|
317
|
-
} = {},
|
|
318
|
-
) => {
|
|
292
|
+
export const forceSystemAdminGroupFailover = async ({ groupJid, targetSessionId, reason = 'admin_force_failover', changedBy = 'admin_api', metadata = null } = {}) => {
|
|
319
293
|
const outcome = await groupOwnershipService.forceAssign({
|
|
320
294
|
groupJid,
|
|
321
295
|
sessionId: targetSessionId,
|
|
@@ -38,6 +38,19 @@ let inFlight = false;
|
|
|
38
38
|
let timerHandle = null;
|
|
39
39
|
let nextDelayMs = EMAIL_AUTOMATION_POLL_INTERVAL_MS;
|
|
40
40
|
|
|
41
|
+
const maskEmailForLogs = (value) => {
|
|
42
|
+
const normalized = String(value || '')
|
|
43
|
+
.trim()
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.slice(0, 255);
|
|
46
|
+
const [localPartRaw, domainRaw] = normalized.split('@');
|
|
47
|
+
const localPart = String(localPartRaw || '').trim();
|
|
48
|
+
const domain = String(domainRaw || '').trim();
|
|
49
|
+
if (!localPart || !domain) return 'invalid-email';
|
|
50
|
+
const localMasked = localPart.length <= 2 ? `${localPart.charAt(0) || '*'}*` : `${localPart.slice(0, 2)}***`;
|
|
51
|
+
return `${localMasked}@${domain}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
41
54
|
const applyDelayJitter = (delayMs) => {
|
|
42
55
|
const baseDelay = Math.max(250, Math.floor(Number(delayMs) || 0));
|
|
43
56
|
if (EMAIL_AUTOMATION_IDLE_JITTER_PERCENT <= 0) return baseDelay;
|
|
@@ -119,6 +132,16 @@ export const runEmailAutomationTick = async ({ maxPerTick = EMAIL_AUTOMATION_MAX
|
|
|
119
132
|
});
|
|
120
133
|
|
|
121
134
|
stats.sent += 1;
|
|
135
|
+
logger.debug('E-mail da fila entregue com sucesso.', {
|
|
136
|
+
action: 'email_automation_delivery_succeeded',
|
|
137
|
+
task_id: task.id,
|
|
138
|
+
template_key: task.template_key || null,
|
|
139
|
+
recipient_email_masked: maskEmailForLogs(task.recipient_email),
|
|
140
|
+
attempts: task.attempts,
|
|
141
|
+
max_attempts: task.max_attempts,
|
|
142
|
+
provider_message_id: delivery?.messageId || null,
|
|
143
|
+
provider_response: delivery?.response || null,
|
|
144
|
+
});
|
|
122
145
|
} catch (error) {
|
|
123
146
|
stats.failed += 1;
|
|
124
147
|
await failEmailOutboxTask(task.id, {
|
|
@@ -129,8 +152,12 @@ export const runEmailAutomationTick = async ({ maxPerTick = EMAIL_AUTOMATION_MAX
|
|
|
129
152
|
logger.warn('Falha ao entregar e-mail da fila.', {
|
|
130
153
|
action: 'email_automation_delivery_failed',
|
|
131
154
|
task_id: task.id,
|
|
132
|
-
|
|
155
|
+
template_key: task.template_key || null,
|
|
156
|
+
recipient_email_masked: maskEmailForLogs(task.recipient_email),
|
|
133
157
|
attempts: task.attempts,
|
|
158
|
+
max_attempts: task.max_attempts,
|
|
159
|
+
retry_delay_seconds: safeRetryDelay,
|
|
160
|
+
metadata_keys: Object.keys(task?.metadata || {}).slice(0, 20),
|
|
134
161
|
error: error?.message,
|
|
135
162
|
});
|
|
136
163
|
}
|
|
@@ -138,6 +165,14 @@ export const runEmailAutomationTick = async ({ maxPerTick = EMAIL_AUTOMATION_MAX
|
|
|
138
165
|
|
|
139
166
|
if (stats.claimed > 0) {
|
|
140
167
|
await refreshQueueDepthMetrics().catch(() => null);
|
|
168
|
+
logger.info('Processamento da fila de e-mail concluído.', {
|
|
169
|
+
action: 'email_automation_tick_processed',
|
|
170
|
+
claimed: stats.claimed,
|
|
171
|
+
sent: stats.sent,
|
|
172
|
+
failed: stats.failed,
|
|
173
|
+
max_per_tick: safeMaxPerTick,
|
|
174
|
+
retry_delay_seconds: safeRetryDelay,
|
|
175
|
+
});
|
|
141
176
|
}
|
|
142
177
|
|
|
143
178
|
return stats;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logger from '#logger';
|
|
1
2
|
import { enqueueEmailOutbox, getEmailOutboxStatusSnapshot } from './emailOutboxRepository.js';
|
|
2
3
|
import { renderEmailTemplate } from './emailTemplateService.js';
|
|
3
4
|
import { getEmailTransportMetadata } from './emailTransportService.js';
|
|
@@ -8,6 +9,22 @@ const normalizeEmail = (value) =>
|
|
|
8
9
|
.toLowerCase()
|
|
9
10
|
.slice(0, 255);
|
|
10
11
|
|
|
12
|
+
const maskEmailForLogs = (value) => {
|
|
13
|
+
const normalized = normalizeEmail(value);
|
|
14
|
+
const [localPartRaw, domainRaw] = normalized.split('@');
|
|
15
|
+
const localPart = String(localPartRaw || '').trim();
|
|
16
|
+
const domain = String(domainRaw || '').trim();
|
|
17
|
+
if (!localPart || !domain) return 'invalid-email';
|
|
18
|
+
const localMasked = localPart.length <= 2 ? `${localPart.charAt(0) || '*'}*` : `${localPart.slice(0, 2)}***`;
|
|
19
|
+
return `${localMasked}@${domain}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const resolveEmailDomain = (value) => {
|
|
23
|
+
const normalized = normalizeEmail(value);
|
|
24
|
+
if (!normalized.includes('@')) return null;
|
|
25
|
+
return normalized.split('@')[1] || null;
|
|
26
|
+
};
|
|
27
|
+
|
|
11
28
|
const normalizeOptionalText = (value, maxLength = 500_000) => {
|
|
12
29
|
const normalized =
|
|
13
30
|
String(value || '')
|
|
@@ -31,6 +48,12 @@ const resolveEmailBodyFromPayload = ({ templateKey = '', templateData = {}, subj
|
|
|
31
48
|
|
|
32
49
|
const renderedTemplate = normalizedTemplateKey ? renderEmailTemplate(normalizedTemplateKey, normalizedTemplateData) : null;
|
|
33
50
|
|
|
51
|
+
if (normalizedTemplateKey && !renderedTemplate && !normalizeOptionalText(subject, 180) && !normalizeOptionalText(text, 120_000) && !normalizeOptionalText(html, 500_000)) {
|
|
52
|
+
const error = new Error(`Template de e-mail inválido ou indisponível: "${normalizedTemplateKey}".`);
|
|
53
|
+
error.statusCode = 400;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
const normalizedSubject = normalizeOptionalText(subject, 180) || renderedTemplate?.subject || '';
|
|
35
58
|
const normalizedText = normalizeOptionalText(text, 120_000) || renderedTemplate?.text || null;
|
|
36
59
|
const normalizedHtml = normalizeOptionalText(html, 500_000) || renderedTemplate?.html || null;
|
|
@@ -72,6 +95,8 @@ export const queueAutomatedEmail = async ({ to, name = '', templateKey = '', tem
|
|
|
72
95
|
html,
|
|
73
96
|
});
|
|
74
97
|
|
|
98
|
+
const safeMetadata = normalizePayloadObject(metadata);
|
|
99
|
+
|
|
75
100
|
const taskId = await enqueueEmailOutbox({
|
|
76
101
|
recipientEmail: normalizedEmail,
|
|
77
102
|
recipientName: normalizeOptionalText(name, 120),
|
|
@@ -80,7 +105,7 @@ export const queueAutomatedEmail = async ({ to, name = '', templateKey = '', tem
|
|
|
80
105
|
htmlBody: body.html_body,
|
|
81
106
|
templateKey: body.template_key,
|
|
82
107
|
templatePayload: body.template_payload,
|
|
83
|
-
metadata:
|
|
108
|
+
metadata: safeMetadata,
|
|
84
109
|
priority,
|
|
85
110
|
scheduledAt,
|
|
86
111
|
maxAttempts,
|
|
@@ -93,6 +118,22 @@ export const queueAutomatedEmail = async ({ to, name = '', templateKey = '', tem
|
|
|
93
118
|
throw error;
|
|
94
119
|
}
|
|
95
120
|
|
|
121
|
+
logger.info('E-mail enfileirado para processamento.', {
|
|
122
|
+
action: 'email_outbox_enqueued',
|
|
123
|
+
task_id: taskId,
|
|
124
|
+
template_key: body.template_key || null,
|
|
125
|
+
recipient_email_masked: maskEmailForLogs(normalizedEmail),
|
|
126
|
+
recipient_domain: resolveEmailDomain(normalizedEmail),
|
|
127
|
+
subject_length: body.subject.length,
|
|
128
|
+
has_text_body: Boolean(body.text_body),
|
|
129
|
+
has_html_body: Boolean(body.html_body),
|
|
130
|
+
metadata_keys: Object.keys(safeMetadata).slice(0, 20),
|
|
131
|
+
priority: Number.isFinite(Number(priority)) ? Number(priority) : null,
|
|
132
|
+
scheduled_at: scheduledAt ? String(scheduledAt) : null,
|
|
133
|
+
max_attempts: Number.isFinite(Number(maxAttempts)) ? Number(maxAttempts) : null,
|
|
134
|
+
idempotency_key_present: Boolean(String(idempotencyKey || '').trim()),
|
|
135
|
+
});
|
|
136
|
+
|
|
96
137
|
return {
|
|
97
138
|
task_id: taskId,
|
|
98
139
|
recipient_email: normalizedEmail,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { now as __timeNow, nowIso as __timeNowIso, toUnixMs as __timeNowMs } from '#time';
|
|
2
|
+
import logger from '#logger';
|
|
2
3
|
import { resolveAdminPhoneFromEnv, resolveBotPhoneFromEnv, resolveSupportPhoneFromEnv } from '../../utils/whatsapp/contactEnv.js';
|
|
3
4
|
const DEFAULT_SITE_ORIGIN = 'https://omnizap.shop';
|
|
4
5
|
const DEFAULT_BRAND_NAME = 'OmniZap';
|
|
6
|
+
const DEFAULT_BRAND_LOGO_PATH = '/assets/images/brand-logo-128.webp';
|
|
5
7
|
|
|
6
8
|
const resolveSiteOrigin = () =>
|
|
7
9
|
String(process.env.SITE_ORIGIN || process.env.WHATSAPP_LOGIN_BASE_URL || DEFAULT_SITE_ORIGIN)
|
|
@@ -28,6 +30,24 @@ const escapeHtml = (value) =>
|
|
|
28
30
|
.replace(/"/g, '"')
|
|
29
31
|
.replace(/'/g, ''');
|
|
30
32
|
|
|
33
|
+
const summarizePayloadKeys = (value, maxItems = 24) => {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
|
35
|
+
return Object.keys(value)
|
|
36
|
+
.map((key) =>
|
|
37
|
+
String(key || '')
|
|
38
|
+
.trim()
|
|
39
|
+
.slice(0, 64),
|
|
40
|
+
)
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.slice(0, maxItems);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const clipTemplatePreview = (value, maxLength = 140) =>
|
|
46
|
+
String(value || '')
|
|
47
|
+
.replace(/\s+/g, ' ')
|
|
48
|
+
.trim()
|
|
49
|
+
.slice(0, maxLength);
|
|
50
|
+
|
|
31
51
|
const normalizeHttpUrl = (value, fallback = '') => {
|
|
32
52
|
const normalized = String(value || '')
|
|
33
53
|
.trim()
|
|
@@ -48,6 +68,19 @@ const normalizeEmailAddress = (value) => {
|
|
|
48
68
|
return candidate.slice(0, 255);
|
|
49
69
|
};
|
|
50
70
|
|
|
71
|
+
const resolveBrandLogoUrl = ({ siteOrigin = '', payloadLogoUrl = '' } = {}) => {
|
|
72
|
+
const fallbackLogoUrl = normalizeHttpUrl(`${String(siteOrigin || DEFAULT_SITE_ORIGIN).replace(/\/+$/, '')}${DEFAULT_BRAND_LOGO_PATH}`, `${DEFAULT_SITE_ORIGIN}${DEFAULT_BRAND_LOGO_PATH}`);
|
|
73
|
+
|
|
74
|
+
const explicitLogoUrl = normalizeHttpUrl(payloadLogoUrl || process.env.EMAIL_BRAND_LOGO_URL || '', '');
|
|
75
|
+
if (!explicitLogoUrl) return fallbackLogoUrl;
|
|
76
|
+
|
|
77
|
+
const explicitLower = explicitLogoUrl.toLowerCase();
|
|
78
|
+
const looksLikeFavicon = explicitLower.endsWith('/favicon.ico') || explicitLower.includes('/favicon-');
|
|
79
|
+
if (looksLikeFavicon) return fallbackLogoUrl;
|
|
80
|
+
|
|
81
|
+
return explicitLogoUrl;
|
|
82
|
+
};
|
|
83
|
+
|
|
51
84
|
const normalizePhoneDigits = (value, maxLength = 20) =>
|
|
52
85
|
String(value || '')
|
|
53
86
|
.replace(/\D+/g, '')
|
|
@@ -89,7 +122,10 @@ const resolveBrandConfig = (payload = {}) => {
|
|
|
89
122
|
siteOrigin,
|
|
90
123
|
brandName: normalizeText(payload?.brandName || process.env.EMAIL_BRAND_NAME || DEFAULT_BRAND_NAME, 80) || DEFAULT_BRAND_NAME,
|
|
91
124
|
brandTagline: normalizeText(payload?.brandTagline || process.env.EMAIL_BRAND_TAGLINE || 'Automação profissional para WhatsApp.', 120) || null,
|
|
92
|
-
brandLogoUrl:
|
|
125
|
+
brandLogoUrl: resolveBrandLogoUrl({
|
|
126
|
+
siteOrigin,
|
|
127
|
+
payloadLogoUrl: payload?.brandLogoUrl || payload?.logoUrl || '',
|
|
128
|
+
}),
|
|
93
129
|
supportUrl: resolvedSupportUrl,
|
|
94
130
|
supportLabel: resolvedSupportLabel || 'Central de suporte',
|
|
95
131
|
supportEmail: replyToAddress || fromAddress || '',
|
|
@@ -128,53 +164,75 @@ const renderEmailLayout = ({ payload = {}, preheader = '', heading = '', greetin
|
|
|
128
164
|
const safeSecurityNote = normalizeText(securityNote, 220);
|
|
129
165
|
const safeFooterMessage = normalizeText(footerMessage, 220);
|
|
130
166
|
const year = __timeNow().getUTCFullYear();
|
|
167
|
+
const generatedAt = __timeNowIso();
|
|
131
168
|
|
|
132
|
-
const logoBlock = brand.brandLogoUrl ? `<img src="${escapeHtml(brand.brandLogoUrl)}" alt="${escapeHtml(brand.brandName)}" width="
|
|
169
|
+
const logoBlock = brand.brandLogoUrl ? `<img src="${escapeHtml(brand.brandLogoUrl)}" alt="${escapeHtml(brand.brandName)}" width="138" style="display:block;border:0;outline:none;text-decoration:none;height:auto;" />` : `<div style="display:inline-block;font-size:24px;font-weight:800;line-height:1.1;color:#ffffff;letter-spacing:0.3px;">${escapeHtml(brand.brandName)}</div>`;
|
|
133
170
|
|
|
134
|
-
const
|
|
135
|
-
const
|
|
171
|
+
const headingBlock = safeHeading ? `<h1 style="margin:0;color:#0f172a;font-size:27px;line-height:1.2;font-weight:800;letter-spacing:0.1px;">${escapeHtml(safeHeading)}</h1>` : '';
|
|
172
|
+
const greetingBlock = safeGreeting ? `<p style="margin:0 0 10px;color:#0f172a;font-size:16px;font-weight:700;line-height:1.6;">${escapeHtml(safeGreeting)}</p>` : '';
|
|
173
|
+
const introBlock = safeIntro ? `<p style="margin:0 0 14px;color:#334155;font-size:15px;line-height:1.7;">${escapeHtml(safeIntro)}</p>` : '';
|
|
136
174
|
const bodyBlock = renderParagraphsHtml(body);
|
|
137
175
|
|
|
138
176
|
const ctaBlock =
|
|
139
177
|
safeCtaUrl && safeCtaLabel
|
|
140
178
|
? `
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
179
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:14px 0 8px;">
|
|
180
|
+
<tr>
|
|
181
|
+
<td align="center" bgcolor="#1d4ed8" style="border-radius:10px;">
|
|
182
|
+
<a href="${escapeHtml(safeCtaUrl)}" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;letter-spacing:0.2px;">${escapeHtml(safeCtaLabel)}</a>
|
|
183
|
+
</td>
|
|
184
|
+
</tr>
|
|
185
|
+
</table>
|
|
186
|
+
`.trim()
|
|
149
187
|
: '';
|
|
150
188
|
|
|
151
|
-
const ctaHintBlock = safeCtaHint ? `<p style="margin:
|
|
152
|
-
const secondaryCtaBlock =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
189
|
+
const ctaHintBlock = safeCtaHint ? `<p style="margin:6px 0 0;color:#64748b;font-size:13px;line-height:1.6;">${escapeHtml(safeCtaHint)}</p>` : '';
|
|
190
|
+
const secondaryCtaBlock =
|
|
191
|
+
safeSecondaryCtaLabel && safeSecondaryCtaUrl
|
|
192
|
+
? `
|
|
193
|
+
<p style="margin:12px 0 0;color:#334155;font-size:13px;line-height:1.65;">
|
|
194
|
+
${escapeHtml(safeSecondaryCtaLabel)}:
|
|
195
|
+
<a href="${escapeHtml(safeSecondaryCtaUrl)}" style="color:#1d4ed8;text-decoration:none;font-weight:600;">${escapeHtml(safeSecondaryCtaUrl)}</a>
|
|
196
|
+
</p>
|
|
197
|
+
`.trim()
|
|
198
|
+
: '';
|
|
199
|
+
const fallbackLinkBlock = safeCtaUrl ? `<p style="margin:14px 0 0;color:#64748b;font-size:12px;line-height:1.7;word-break:break-all;">Se o botão não funcionar, copie e cole este link no navegador: ${escapeHtml(safeCtaUrl)}</p>` : '';
|
|
200
|
+
const securityNoteBlock = safeSecurityNote ? `<p style="margin:16px 0 0;padding:12px 13px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;color:#475569;font-size:12px;line-height:1.65;">${escapeHtml(safeSecurityNote)}</p>` : '';
|
|
201
|
+
const brandTaglineBlock = brand.brandTagline ? `<p style="margin:8px 0 0;color:#cbd5e1;font-size:13px;line-height:1.6;">${escapeHtml(brand.brandTagline)}</p>` : '';
|
|
156
202
|
const supportEmailLine = brand.supportEmail ? `<span style="display:block;margin-top:6px;">E-mail: <a href="mailto:${escapeHtml(brand.supportEmail)}" style="color:#2563eb;text-decoration:none;">${escapeHtml(brand.supportEmail)}</a></span>` : '';
|
|
157
|
-
const footerMessageBlock = safeFooterMessage ? `<span style="display:block;margin-top:
|
|
158
|
-
const taglineBlock = brand.brandTagline ? `<p style="margin:8px 0 0;color:#64748b;font-size:13px;line-height:1.6;">${escapeHtml(brand.brandTagline)}</p>` : '';
|
|
203
|
+
const footerMessageBlock = safeFooterMessage ? `<span style="display:block;margin-top:7px;color:#64748b;">${escapeHtml(safeFooterMessage)}</span>` : '';
|
|
159
204
|
|
|
160
205
|
return `
|
|
161
206
|
<!doctype html>
|
|
162
207
|
<html lang="pt-BR">
|
|
163
|
-
<body style="margin:0;padding:0;background:#
|
|
208
|
+
<body style="margin:0;padding:0;background:#eef2f8;font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
164
209
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(safePreheader)}</div>
|
|
165
|
-
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#
|
|
210
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#eef2f8;padding:26px 10px;">
|
|
166
211
|
<tr>
|
|
167
212
|
<td align="center">
|
|
168
213
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:640px;">
|
|
169
214
|
<tr>
|
|
170
|
-
<td
|
|
171
|
-
|
|
172
|
-
|
|
215
|
+
<td style="background:#0f172a;border-radius:16px 16px 0 0;padding:22px 24px 20px;">
|
|
216
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
217
|
+
<tr>
|
|
218
|
+
<td align="left" valign="middle" style="padding-right:12px;">
|
|
219
|
+
${logoBlock}
|
|
220
|
+
${brandTaglineBlock}
|
|
221
|
+
</td>
|
|
222
|
+
<td align="right" valign="middle" style="white-space:nowrap;">
|
|
223
|
+
<span style="display:inline-block;padding:6px 10px;border:1px solid rgba(148,163,184,0.45);border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.35px;text-transform:uppercase;color:#e2e8f0;">Comunicado oficial</span>
|
|
224
|
+
</td>
|
|
225
|
+
</tr>
|
|
226
|
+
</table>
|
|
227
|
+
</td>
|
|
228
|
+
</tr>
|
|
229
|
+
<tr>
|
|
230
|
+
<td style="background:#ffffff;border-left:1px solid #d9e2ef;border-right:1px solid #d9e2ef;padding:26px 24px 10px;">
|
|
231
|
+
${headingBlock}
|
|
173
232
|
</td>
|
|
174
233
|
</tr>
|
|
175
234
|
<tr>
|
|
176
|
-
<td style="background:#ffffff;border:1px solid #
|
|
177
|
-
<h1 style="margin:0 0 14px;color:#0f172a;font-size:25px;line-height:1.25;">${escapeHtml(safeHeading)}</h1>
|
|
235
|
+
<td style="background:#ffffff;border-left:1px solid #d9e2ef;border-right:1px solid #d9e2ef;padding:0 24px 22px;">
|
|
178
236
|
${greetingBlock}
|
|
179
237
|
${introBlock}
|
|
180
238
|
${bodyBlock}
|
|
@@ -186,11 +244,12 @@ const renderEmailLayout = ({ payload = {}, preheader = '', heading = '', greetin
|
|
|
186
244
|
</td>
|
|
187
245
|
</tr>
|
|
188
246
|
<tr>
|
|
189
|
-
<td style="padding:14px
|
|
247
|
+
<td style="background:#ffffff;border:1px solid #d9e2ef;border-top:none;border-radius:0 0 16px 16px;padding:14px 24px 18px;color:#64748b;font-size:12px;line-height:1.7;">
|
|
190
248
|
<span style="display:block;">${escapeHtml(brand.brandName)} © ${year}. Todos os direitos reservados.</span>
|
|
191
249
|
<span style="display:block;margin-top:6px;">Central de suporte: <a href="${escapeHtml(brand.supportUrl)}" style="color:#2563eb;text-decoration:none;">${escapeHtml(brand.supportLabel)}</a></span>
|
|
192
250
|
${supportEmailLine}
|
|
193
251
|
${footerMessageBlock}
|
|
252
|
+
<span style="display:block;margin-top:7px;color:#94a3b8;font-size:11px;">Gerado em ${escapeHtml(generatedAt)} UTC.</span>
|
|
194
253
|
</td>
|
|
195
254
|
</tr>
|
|
196
255
|
</table>
|
|
@@ -457,19 +516,66 @@ const TEMPLATE_BUILDERS = {
|
|
|
457
516
|
};
|
|
458
517
|
|
|
459
518
|
export const renderEmailTemplate = (templateKey, payload = {}) => {
|
|
519
|
+
const renderStartedAtMs = __timeNowMs();
|
|
460
520
|
const normalizedTemplateKey = normalizeTemplateKey(templateKey);
|
|
461
|
-
|
|
521
|
+
const payloadKeys = summarizePayloadKeys(payload);
|
|
522
|
+
|
|
523
|
+
if (!normalizedTemplateKey) {
|
|
524
|
+
logger.warn('Render de template de e-mail ignorado por chave inválida.', {
|
|
525
|
+
action: 'email_template_render_invalid_key',
|
|
526
|
+
template_key_raw: clipTemplatePreview(templateKey, 64),
|
|
527
|
+
payload_keys: payloadKeys,
|
|
528
|
+
});
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
462
531
|
const builder = TEMPLATE_BUILDERS[normalizedTemplateKey];
|
|
463
|
-
if (typeof builder !== 'function')
|
|
532
|
+
if (typeof builder !== 'function') {
|
|
533
|
+
logger.warn('Template de e-mail não encontrado.', {
|
|
534
|
+
action: 'email_template_builder_not_found',
|
|
535
|
+
template_key: normalizedTemplateKey,
|
|
536
|
+
payload_keys: payloadKeys,
|
|
537
|
+
});
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
464
540
|
|
|
465
541
|
const rendered = builder(payload || {});
|
|
466
|
-
if (!rendered)
|
|
542
|
+
if (!rendered) {
|
|
543
|
+
logger.warn('Template de e-mail retornou conteúdo vazio.', {
|
|
544
|
+
action: 'email_template_builder_empty',
|
|
545
|
+
template_key: normalizedTemplateKey,
|
|
546
|
+
payload_keys: payloadKeys,
|
|
547
|
+
});
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
467
550
|
|
|
468
551
|
const subject = normalizeText(rendered.subject, 180);
|
|
469
552
|
const text = normalizeText(rendered.text, 120_000);
|
|
470
553
|
const html = normalizeText(rendered.html, 500_000);
|
|
471
554
|
|
|
472
|
-
if (!subject || (!text && !html))
|
|
555
|
+
if (!subject || (!text && !html)) {
|
|
556
|
+
logger.warn('Template de e-mail inválido após normalização.', {
|
|
557
|
+
action: 'email_template_render_invalid_output',
|
|
558
|
+
template_key: normalizedTemplateKey,
|
|
559
|
+
subject_length: subject.length,
|
|
560
|
+
text_length: text.length,
|
|
561
|
+
html_length: html.length,
|
|
562
|
+
payload_keys: payloadKeys,
|
|
563
|
+
});
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
logger.debug('Template de e-mail renderizado com sucesso.', {
|
|
568
|
+
action: 'email_template_rendered',
|
|
569
|
+
template_key: normalizedTemplateKey,
|
|
570
|
+
subject_preview: clipTemplatePreview(subject),
|
|
571
|
+
subject_length: subject.length,
|
|
572
|
+
text_length: text.length,
|
|
573
|
+
html_length: html.length,
|
|
574
|
+
has_text: Boolean(text),
|
|
575
|
+
has_html: Boolean(html),
|
|
576
|
+
payload_keys: payloadKeys,
|
|
577
|
+
render_duration_ms: Math.max(0, __timeNowMs() - renderStartedAtMs),
|
|
578
|
+
});
|
|
473
579
|
|
|
474
580
|
return {
|
|
475
581
|
subject,
|
|
@@ -225,7 +225,7 @@ export const buildCookieString = (name, value, req, options = {}) => {
|
|
|
225
225
|
return parts.join('; ');
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
-
export const
|
|
228
|
+
export const readRawBody = async (req, { maxBytes = 64 * 1024 } = {}) =>
|
|
229
229
|
new Promise((resolve, reject) => {
|
|
230
230
|
const chunks = [];
|
|
231
231
|
let total = 0;
|
|
@@ -243,20 +243,24 @@ export const readJsonBody = async (req, { maxBytes = 64 * 1024 } = {}) =>
|
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
req.on('end', () => {
|
|
246
|
-
|
|
247
|
-
if (!raw) {
|
|
248
|
-
resolve({});
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
resolve(JSON.parse(raw));
|
|
254
|
-
} catch {
|
|
255
|
-
const error = new Error('JSON invalido.');
|
|
256
|
-
error.statusCode = 400;
|
|
257
|
-
reject(error);
|
|
258
|
-
}
|
|
246
|
+
resolve(Buffer.concat(chunks));
|
|
259
247
|
});
|
|
260
248
|
|
|
261
249
|
req.on('error', (error) => reject(error));
|
|
262
250
|
});
|
|
251
|
+
|
|
252
|
+
export const readJsonBody = async (req, { maxBytes = 64 * 1024 } = {}) => {
|
|
253
|
+
const rawBuffer = await readRawBody(req, { maxBytes });
|
|
254
|
+
const raw = rawBuffer.toString('utf8').trim();
|
|
255
|
+
if (!raw) {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(raw);
|
|
261
|
+
} catch {
|
|
262
|
+
const error = new Error('JSON invalido.');
|
|
263
|
+
error.statusCode = 400;
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
@@ -39,7 +39,9 @@ const HELMET_CSP_DIRECTIVES = {
|
|
|
39
39
|
baseUri: ["'self'"],
|
|
40
40
|
objectSrc: ["'none'"],
|
|
41
41
|
frameAncestors: ["'self'"],
|
|
42
|
-
|
|
42
|
+
// Google Identity Services usa submit interno para accounts.google.com/gsi/transform.
|
|
43
|
+
// Sem essa origem no form-action o login pode travar na etapa de transform.
|
|
44
|
+
formAction: ["'self'", 'https://accounts.google.com'],
|
|
43
45
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://accounts.google.com', 'https://cdn.tailwindcss.com'],
|
|
44
46
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
|
|
45
47
|
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
@@ -71,6 +73,11 @@ const helmetMiddleware = helmet({
|
|
|
71
73
|
directives: HELMET_CSP_DIRECTIVES,
|
|
72
74
|
reportOnly: !HELMET_CSP_ENFORCE,
|
|
73
75
|
},
|
|
76
|
+
// Fluxos OAuth/FedCM em popup (Google GIS) podem quebrar com COOP strict.
|
|
77
|
+
// Permitimos opener em popups mantendo isolamento para navegação principal.
|
|
78
|
+
crossOriginOpenerPolicy: {
|
|
79
|
+
policy: 'same-origin-allow-popups',
|
|
80
|
+
},
|
|
74
81
|
crossOriginEmbedderPolicy: false,
|
|
75
82
|
// Mantemos permissões explícitas para browser APIs sensíveis.
|
|
76
83
|
permissionsPolicy: {
|
|
@@ -78,6 +85,7 @@ const helmetMiddleware = helmet({
|
|
|
78
85
|
geolocation: [],
|
|
79
86
|
microphone: [],
|
|
80
87
|
camera: [],
|
|
88
|
+
'identity-credentials-get': ['self'],
|
|
81
89
|
},
|
|
82
90
|
},
|
|
83
91
|
});
|
|
@@ -86,7 +94,12 @@ const applyFallbackHeaders = (res) => {
|
|
|
86
94
|
if (!res.getHeader('X-Content-Type-Options')) res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
87
95
|
if (!res.getHeader('X-Frame-Options')) res.setHeader('X-Frame-Options', 'DENY');
|
|
88
96
|
if (!res.getHeader('Referrer-Policy')) res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
89
|
-
if (!res.getHeader('Permissions-Policy'))
|
|
97
|
+
if (!res.getHeader('Permissions-Policy')) {
|
|
98
|
+
res.setHeader(
|
|
99
|
+
'Permissions-Policy',
|
|
100
|
+
'geolocation=(), microphone=(), camera=(), identity-credentials-get=(self)',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
90
103
|
if (FALLBACK_CSP_HEADER && !res.getHeader('Content-Security-Policy') && !res.getHeader('Content-Security-Policy-Report-Only')) {
|
|
91
104
|
const cspHeaderName = HELMET_CSP_ENFORCE ? 'Content-Security-Policy' : 'Content-Security-Policy-Report-Only';
|
|
92
105
|
res.setHeader(cspHeaderName, FALLBACK_CSP_HEADER);
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { maybeHandleMetricsRequest } from './metrics/metricsRouter.js';
|
|
4
4
|
import { maybeHandleHealthRequest, shouldHandleHealthPath } from './health/healthRouter.js';
|
|
5
5
|
import { getEmailAutomationRouterConfig, maybeHandleEmailAutomationRequest, shouldHandleEmailAutomationPath } from './email/emailAutomationRouter.js';
|
|
6
|
+
import { getPaymentsRouterConfig, maybeHandlePaymentsRequest, shouldHandlePaymentsPath } from './payments/paymentsRouter.js';
|
|
6
7
|
import { buildUserApiPaths, getUserRouterConfig, maybeHandleUserRequest, shouldHandleUserPath } from './user/userRouter.js';
|
|
7
8
|
import { getSystemAdminRouterConfig, maybeHandleSystemAdminRequest, shouldHandleSystemAdminPath } from './admin/systemAdminRouter.js';
|
|
8
9
|
import { getStickerSiteRouterConfig, maybeHandleStickerSiteRequest, shouldHandleStickerSitePath } from './sticker/stickerSiteRouter.js';
|
|
@@ -76,6 +77,16 @@ const loadEmailAutomationConfigSafe = async () => {
|
|
|
76
77
|
}
|
|
77
78
|
};
|
|
78
79
|
|
|
80
|
+
const loadPaymentsConfigSafe = async () => {
|
|
81
|
+
try {
|
|
82
|
+
return await getPaymentsRouterConfig();
|
|
83
|
+
} catch {
|
|
84
|
+
return {
|
|
85
|
+
apiBasePath: '/api/payments',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
79
90
|
const loadStickerSiteConfigSafe = async () => {
|
|
80
91
|
try {
|
|
81
92
|
return await getStickerSiteRouterConfig();
|
|
@@ -129,10 +140,11 @@ const loadGrafanaProxyConfigSafe = async () => {
|
|
|
129
140
|
|
|
130
141
|
export const getIndexRouteConfigs = async () => {
|
|
131
142
|
if (!indexRouteConfigsPromise) {
|
|
132
|
-
indexRouteConfigsPromise = Promise.all([loadUserConfigSafe(), loadSystemAdminConfigSafe(), loadEmailAutomationConfigSafe(), loadStickerSiteConfigSafe(), loadStickerDataConfigSafe(), loadStickerApiConfigSafe(), loadGrafanaProxyConfigSafe()]).then(([userConfig, systemAdminConfig, emailAutomationConfig, stickerSiteConfig, stickerDataConfig, stickerApiConfig, grafanaProxyConfig]) => ({
|
|
143
|
+
indexRouteConfigsPromise = Promise.all([loadUserConfigSafe(), loadSystemAdminConfigSafe(), loadEmailAutomationConfigSafe(), loadPaymentsConfigSafe(), loadStickerSiteConfigSafe(), loadStickerDataConfigSafe(), loadStickerApiConfigSafe(), loadGrafanaProxyConfigSafe()]).then(([userConfig, systemAdminConfig, emailAutomationConfig, paymentsConfig, stickerSiteConfig, stickerDataConfig, stickerApiConfig, grafanaProxyConfig]) => ({
|
|
133
144
|
userConfig,
|
|
134
145
|
systemAdminConfig,
|
|
135
146
|
emailAutomationConfig,
|
|
147
|
+
paymentsConfig,
|
|
136
148
|
grafanaProxyConfig,
|
|
137
149
|
stickerConfig: {
|
|
138
150
|
...stickerSiteConfig,
|
|
@@ -156,6 +168,7 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
156
168
|
const userConfig = resolvedConfigs?.userConfig || null;
|
|
157
169
|
const systemAdminConfig = resolvedConfigs?.systemAdminConfig || null;
|
|
158
170
|
const emailAutomationConfig = resolvedConfigs?.emailAutomationConfig || null;
|
|
171
|
+
const paymentsConfig = resolvedConfigs?.paymentsConfig || null;
|
|
159
172
|
const grafanaProxyConfig = resolvedConfigs?.grafanaProxyConfig || null;
|
|
160
173
|
const stickerConfig = resolvedConfigs?.stickerConfig || null;
|
|
161
174
|
|
|
@@ -180,14 +193,21 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
180
193
|
return sendNotFound(req, res);
|
|
181
194
|
}
|
|
182
195
|
|
|
183
|
-
// 4)
|
|
196
|
+
// 4) Payments API
|
|
197
|
+
if (shouldHandlePaymentsPath(pathname, paymentsConfig)) {
|
|
198
|
+
const handled = await maybeHandlePaymentsRequest(req, res, { pathname, url });
|
|
199
|
+
if (handled) return true;
|
|
200
|
+
return sendNotFound(req, res);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 5) Grafana proxy (/api/grafana e alias /grafana)
|
|
184
204
|
if (shouldHandleGrafanaProxyPath(pathname, grafanaProxyConfig)) {
|
|
185
205
|
const handled = await maybeHandleGrafanaProxyRequest(req, res, { pathname, url, config: grafanaProxyConfig });
|
|
186
206
|
if (handled) return true;
|
|
187
207
|
return sendNotFound(req, res);
|
|
188
208
|
}
|
|
189
209
|
|
|
190
|
-
//
|
|
210
|
+
// 6) User
|
|
191
211
|
const systemAdminCandidate = shouldHandleSystemAdminStep(pathname, systemAdminConfig);
|
|
192
212
|
if (shouldHandleUserStep(pathname, userConfig)) {
|
|
193
213
|
const handled = await maybeHandleUserRequest(req, res, { pathname, url });
|
|
@@ -197,14 +217,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
197
217
|
if (!systemAdminCandidate) return sendNotFound(req, res);
|
|
198
218
|
}
|
|
199
219
|
|
|
200
|
-
//
|
|
220
|
+
// 7) System admin + legacy /stickers/admin
|
|
201
221
|
if (systemAdminCandidate) {
|
|
202
222
|
const handled = await maybeHandleSystemAdminRequest(req, res, { pathname, url });
|
|
203
223
|
if (handled) return true;
|
|
204
224
|
return sendNotFound(req, res);
|
|
205
225
|
}
|
|
206
226
|
|
|
207
|
-
//
|
|
227
|
+
// 8) Sticker catalog apenas nos prefixes permitidos
|
|
208
228
|
if (shouldHandleStickerSitePath(pathname, stickerConfig)) {
|
|
209
229
|
const handled = await maybeHandleStickerSiteRequest(req, res, { pathname, url });
|
|
210
230
|
if (handled) return true;
|
|
@@ -236,14 +256,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
|
|
|
236
256
|
return sendNotFound(req, res);
|
|
237
257
|
}
|
|
238
258
|
|
|
239
|
-
//
|
|
259
|
+
// 9) Paginas estaticas (templates em public/pages)
|
|
240
260
|
if (shouldHandleStaticPagePath(pathname)) {
|
|
241
261
|
const handled = await maybeHandleStaticPageRequest(req, res, { pathname });
|
|
242
262
|
if (handled) return true;
|
|
243
263
|
return sendNotFound(req, res);
|
|
244
264
|
}
|
|
245
265
|
|
|
246
|
-
//
|
|
266
|
+
// 10) 404 global
|
|
247
267
|
return sendNotFound(req, res);
|
|
248
268
|
};
|
|
249
269
|
|