@kaikybrofc/omnizap-system 2.3.4 → 2.3.6
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 +541 -468
- package/.github/workflows/codeql.yml +101 -0
- package/README.md +14 -14
- package/app/modules/stickerModule/stickerCommand.js +1 -6
- package/app/modules/stickerModule/stickerTextCommand.js +1 -6
- package/ml/clip_classifier/requirements.txt +2 -2
- package/package.json +1 -1
- package/public/index.html +8 -8
- package/public/js/apps/homeApp.js +22 -18
- package/public/js/apps/loginApp.js +3 -1
- package/public/js/apps/stickersApp.js +145 -319
- package/public/js/apps/userProfileApp.js +0 -9
- package/public/user/index.html +224 -120
- package/server/controllers/admin/adminBanService.js +138 -0
- package/server/controllers/admin/adminPanelHandlers.js +1965 -0
- package/server/controllers/{systemAdminController.js → admin/systemAdminController.js} +2 -2
- package/server/controllers/{stickerCatalogController.js → sticker/stickerCatalogController.js} +129 -2116
- package/server/controllers/userController.js +1 -1
- package/server/routes/admin/systemAdminRouter.js +1 -1
- package/server/routes/indexRouter.js +3 -3
- package/server/routes/{stickerCatalog → sticker}/stickerApiRouter.js +1 -1
- package/server/routes/{stickerCatalog → sticker}/stickerDataRouter.js +1 -1
- package/server/routes/{stickerCatalog → sticker}/stickerSiteRouter.js +1 -1
- /package/server/controllers/{stickerCatalog → sticker}/nonCatalogHandlers.js +0 -0
- /package/server/routes/{stickerCatalog → sticker}/catalogHandlers/catalogAdminHttp.js +0 -0
- /package/server/routes/{stickerCatalog → sticker}/catalogHandlers/catalogAuthHttp.js +0 -0
- /package/server/routes/{stickerCatalog → sticker}/catalogHandlers/catalogPublicHttp.js +0 -0
- /package/server/routes/{stickerCatalog → sticker}/catalogHandlers/catalogUploadHttp.js +0 -0
- /package/server/routes/{stickerCatalog → sticker}/catalogRouter.js +0 -0
|
@@ -0,0 +1,1965 @@
|
|
|
1
|
+
import { randomUUID, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { URLSearchParams } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export const createStickerCatalogAdminHandlers = ({ executeQuery, tables, logger, sendJson, readJsonBody, parseCookies, getCookieValuesFromRequest, appendSetCookie, buildCookieString, sanitizeText, normalizeGoogleSubject, normalizeEmail, normalizeJid, toIsoOrNull, toWhatsAppPhoneDigits, mapGoogleSessionResponseData, resolveGoogleWebSessionFromRequest, revokeGoogleWebSessionsByIdentity, getMarketplaceGlobalStatsCached, getSystemSummaryCached, getFeatureFlagsSnapshot, refreshFeatureFlags, listAdminBans, createAdminBanRecord, revokeAdminBanRecord, normalizeVisitPath, stickerWebPath, findStickerPackByPackKey, stickerPackService, buildManagedPackResponseData, sendManagedMutationStatus, sendManagedPackMutationStatus, deleteManagedPackWithCleanup, mapStickerPackWebManageError, cleanupOrphanStickerAssets, invalidateStickerCatalogDerivedCaches }) => {
|
|
5
|
+
const TABLES = tables;
|
|
6
|
+
const STICKER_WEB_PATH = String(stickerWebPath || '/stickers').trim() || '/stickers';
|
|
7
|
+
|
|
8
|
+
const ADMIN_PANEL_EMAIL = String(process.env.ADM_EMAIL || '')
|
|
9
|
+
.trim()
|
|
10
|
+
.toLowerCase();
|
|
11
|
+
const ADMIN_PANEL_PASSWORD = String(process.env.ADM_PANEL_PASSWORD || process.env.ADM_PANEL || '').trim();
|
|
12
|
+
const ADMIN_PANEL_ENABLED = Boolean(ADMIN_PANEL_EMAIL && ADMIN_PANEL_PASSWORD);
|
|
13
|
+
const ADMIN_PANEL_SESSION_TTL_MS = Math.max(10 * 60 * 1000, Number(process.env.ADM_PANEL_SESSION_TTL_MS) || 12 * 60 * 60 * 1000);
|
|
14
|
+
const ADMIN_MODERATOR_PASSWORD_MIN_LENGTH = Math.max(6, Number(process.env.ADM_MODERATOR_PASSWORD_MIN_LENGTH) || 8);
|
|
15
|
+
const ADMIN_PANEL_SESSION_COOKIE_NAME = 'omnizap_admin_panel_session';
|
|
16
|
+
|
|
17
|
+
const adminPanelSessionMap = new Map();
|
|
18
|
+
let adminPanelSessionPruneAt = 0;
|
|
19
|
+
|
|
20
|
+
const constantTimeStringEqual = (a, b) => {
|
|
21
|
+
const left = Buffer.from(String(a || ''), 'utf8');
|
|
22
|
+
const right = Buffer.from(String(b || ''), 'utf8');
|
|
23
|
+
if (left.length !== right.length) return false;
|
|
24
|
+
try {
|
|
25
|
+
return timingSafeEqual(left, right);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const normalizeAdminPanelRole = (value, fallback = 'owner') => {
|
|
31
|
+
const normalized = String(value || '')
|
|
32
|
+
.trim()
|
|
33
|
+
.toLowerCase();
|
|
34
|
+
if (normalized === 'moderator') return 'moderator';
|
|
35
|
+
if (normalized === 'owner') return 'owner';
|
|
36
|
+
return fallback;
|
|
37
|
+
};
|
|
38
|
+
const hashAdminModeratorPassword = (password) => {
|
|
39
|
+
const normalized = String(password || '');
|
|
40
|
+
const salt = randomBytes(16).toString('hex');
|
|
41
|
+
const hash = scryptSync(normalized, salt, 64).toString('hex');
|
|
42
|
+
return `scrypt$${salt}$${hash}`;
|
|
43
|
+
};
|
|
44
|
+
const verifyAdminModeratorPassword = (password, encodedHash) => {
|
|
45
|
+
const raw = String(encodedHash || '').trim();
|
|
46
|
+
if (!raw) return false;
|
|
47
|
+
const parts = raw.split('$');
|
|
48
|
+
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
|
49
|
+
const salt = String(parts[1] || '').trim();
|
|
50
|
+
const expectedHex = String(parts[2] || '').trim();
|
|
51
|
+
if (!salt || !expectedHex) return false;
|
|
52
|
+
let expectedBuffer;
|
|
53
|
+
try {
|
|
54
|
+
expectedBuffer = Buffer.from(expectedHex, 'hex');
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (!expectedBuffer.length) return false;
|
|
59
|
+
const derived = scryptSync(String(password || ''), salt, expectedBuffer.length);
|
|
60
|
+
try {
|
|
61
|
+
return timingSafeEqual(expectedBuffer, derived);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const mapAdminModeratorRow = (row) => {
|
|
67
|
+
if (!row || typeof row !== 'object') return null;
|
|
68
|
+
return {
|
|
69
|
+
google_sub: normalizeGoogleSubject(row.google_sub),
|
|
70
|
+
email: normalizeEmail(row.email),
|
|
71
|
+
owner_jid: normalizeJid(row.owner_jid) || null,
|
|
72
|
+
name: sanitizeText(row.name || '', 120, { allowEmpty: true }) || null,
|
|
73
|
+
created_by_google_sub: normalizeGoogleSubject(row.created_by_google_sub),
|
|
74
|
+
created_by_email: normalizeEmail(row.created_by_email),
|
|
75
|
+
updated_by_google_sub: normalizeGoogleSubject(row.updated_by_google_sub),
|
|
76
|
+
updated_by_email: normalizeEmail(row.updated_by_email),
|
|
77
|
+
last_login_at: toIsoOrNull(row.last_login_at),
|
|
78
|
+
created_at: toIsoOrNull(row.created_at),
|
|
79
|
+
updated_at: toIsoOrNull(row.updated_at),
|
|
80
|
+
revoked_at: toIsoOrNull(row.revoked_at),
|
|
81
|
+
active: !row.revoked_at,
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const listAdminModerators = async ({ activeOnly = false, limit = 200 } = {}) => {
|
|
86
|
+
const safeLimit = Math.max(1, Math.min(500, Number(limit || 200)));
|
|
87
|
+
const rows = await executeQuery(
|
|
88
|
+
`SELECT google_sub, email, owner_jid, name, created_by_google_sub, created_by_email, updated_by_google_sub, updated_by_email,
|
|
89
|
+
last_login_at, created_at, updated_at, revoked_at
|
|
90
|
+
FROM ${TABLES.STICKER_WEB_ADMIN_MODERATOR}
|
|
91
|
+
${activeOnly ? 'WHERE revoked_at IS NULL' : ''}
|
|
92
|
+
ORDER BY updated_at DESC
|
|
93
|
+
LIMIT ${safeLimit}`,
|
|
94
|
+
);
|
|
95
|
+
return (Array.isArray(rows) ? rows : []).map(mapAdminModeratorRow).filter(Boolean);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const findAdminModeratorByGoogleSub = async (googleSub, { activeOnly = false } = {}) => {
|
|
99
|
+
const normalizedSub = normalizeGoogleSubject(googleSub);
|
|
100
|
+
if (!normalizedSub) return null;
|
|
101
|
+
const rows = await executeQuery(
|
|
102
|
+
`SELECT *
|
|
103
|
+
FROM ${TABLES.STICKER_WEB_ADMIN_MODERATOR}
|
|
104
|
+
WHERE google_sub = ?
|
|
105
|
+
${activeOnly ? 'AND revoked_at IS NULL' : ''}
|
|
106
|
+
LIMIT 1`,
|
|
107
|
+
[normalizedSub],
|
|
108
|
+
);
|
|
109
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const resolveKnownGoogleUserForModerator = async ({ googleSub = '', email = '', ownerJid = '' } = {}) => {
|
|
113
|
+
const normalizedSub = normalizeGoogleSubject(googleSub);
|
|
114
|
+
const normalizedEmail = normalizeEmail(email);
|
|
115
|
+
const normalizedOwnerJid = normalizeJid(ownerJid) || '';
|
|
116
|
+
const clauses = [];
|
|
117
|
+
const params = [];
|
|
118
|
+
|
|
119
|
+
if (normalizedSub) {
|
|
120
|
+
clauses.push('google_sub = ?');
|
|
121
|
+
params.push(normalizedSub);
|
|
122
|
+
}
|
|
123
|
+
if (normalizedEmail) {
|
|
124
|
+
clauses.push('email = ?');
|
|
125
|
+
params.push(normalizedEmail);
|
|
126
|
+
}
|
|
127
|
+
if (normalizedOwnerJid) {
|
|
128
|
+
clauses.push('owner_jid = ?');
|
|
129
|
+
params.push(normalizedOwnerJid);
|
|
130
|
+
}
|
|
131
|
+
if (!clauses.length) return null;
|
|
132
|
+
|
|
133
|
+
const rows = await executeQuery(
|
|
134
|
+
`SELECT google_sub, email, owner_jid, name
|
|
135
|
+
FROM ${TABLES.STICKER_WEB_GOOGLE_USER}
|
|
136
|
+
WHERE ${clauses.join(' OR ')}
|
|
137
|
+
ORDER BY COALESCE(last_seen_at, last_login_at, updated_at, created_at) DESC
|
|
138
|
+
LIMIT 1`,
|
|
139
|
+
params,
|
|
140
|
+
);
|
|
141
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
142
|
+
if (!row) return null;
|
|
143
|
+
const resolvedGoogleSub = normalizeGoogleSubject(row.google_sub);
|
|
144
|
+
const resolvedEmail = normalizeEmail(row.email);
|
|
145
|
+
const resolvedOwnerJid = normalizeJid(row.owner_jid) || '';
|
|
146
|
+
if (!resolvedGoogleSub || !resolvedEmail || !resolvedOwnerJid) return null;
|
|
147
|
+
return {
|
|
148
|
+
google_sub: resolvedGoogleSub,
|
|
149
|
+
email: resolvedEmail,
|
|
150
|
+
owner_jid: resolvedOwnerJid,
|
|
151
|
+
name: sanitizeText(row.name || '', 120, { allowEmpty: true }) || null,
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const findActiveAdminModeratorForIdentity = async ({ googleSub = '', email = '', ownerJid = '' } = {}) => {
|
|
156
|
+
const normalizedSub = normalizeGoogleSubject(googleSub);
|
|
157
|
+
const normalizedEmail = normalizeEmail(email);
|
|
158
|
+
const normalizedOwnerJid = normalizeJid(ownerJid) || '';
|
|
159
|
+
const clauses = [];
|
|
160
|
+
const params = [];
|
|
161
|
+
if (normalizedSub) {
|
|
162
|
+
clauses.push('google_sub = ?');
|
|
163
|
+
params.push(normalizedSub);
|
|
164
|
+
}
|
|
165
|
+
if (normalizedEmail) {
|
|
166
|
+
clauses.push('email = ?');
|
|
167
|
+
params.push(normalizedEmail);
|
|
168
|
+
}
|
|
169
|
+
if (normalizedOwnerJid) {
|
|
170
|
+
clauses.push('owner_jid = ?');
|
|
171
|
+
params.push(normalizedOwnerJid);
|
|
172
|
+
}
|
|
173
|
+
if (!clauses.length) return null;
|
|
174
|
+
|
|
175
|
+
const rows = await executeQuery(
|
|
176
|
+
`SELECT *
|
|
177
|
+
FROM ${TABLES.STICKER_WEB_ADMIN_MODERATOR}
|
|
178
|
+
WHERE revoked_at IS NULL
|
|
179
|
+
AND (${clauses.join(' OR ')})
|
|
180
|
+
ORDER BY updated_at DESC
|
|
181
|
+
LIMIT 1`,
|
|
182
|
+
params,
|
|
183
|
+
);
|
|
184
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const pruneModeratorAdminPanelSessions = ({ googleSub = '', email = '', ownerJid = '' } = {}) => {
|
|
188
|
+
const normalizedSub = normalizeGoogleSubject(googleSub);
|
|
189
|
+
const normalizedEmail = normalizeEmail(email);
|
|
190
|
+
const normalizedOwnerJid = normalizeJid(ownerJid) || '';
|
|
191
|
+
if (!normalizedSub && !normalizedEmail && !normalizedOwnerJid) return;
|
|
192
|
+
|
|
193
|
+
for (const [token, session] of adminPanelSessionMap.entries()) {
|
|
194
|
+
if (!session || normalizeAdminPanelRole(session.role, 'owner') !== 'moderator') continue;
|
|
195
|
+
const sessionSub = normalizeGoogleSubject(session.googleSub);
|
|
196
|
+
const sessionEmail = normalizeEmail(session.email);
|
|
197
|
+
const sessionOwner = normalizeJid(session.ownerJid) || '';
|
|
198
|
+
if ((normalizedSub && sessionSub === normalizedSub) || (normalizedEmail && sessionEmail === normalizedEmail) || (normalizedOwnerJid && sessionOwner === normalizedOwnerJid)) {
|
|
199
|
+
adminPanelSessionMap.delete(token);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const upsertAdminModeratorRecord = async ({ googleSub = '', email = '', ownerJid = '', password = '', adminSession = null }) => {
|
|
205
|
+
const cleanPassword = String(password || '').trim();
|
|
206
|
+
if (cleanPassword.length < ADMIN_MODERATOR_PASSWORD_MIN_LENGTH) {
|
|
207
|
+
const error = new Error(`Senha do moderador deve ter no minimo ${ADMIN_MODERATOR_PASSWORD_MIN_LENGTH} caracteres.`);
|
|
208
|
+
error.statusCode = 400;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const knownUser = await resolveKnownGoogleUserForModerator({ googleSub, email, ownerJid });
|
|
213
|
+
if (!knownUser?.google_sub) {
|
|
214
|
+
const error = new Error('Somente usuarios Google logados no site podem virar moderadores.');
|
|
215
|
+
error.statusCode = 400;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const existing = await findAdminModeratorByGoogleSub(knownUser.google_sub);
|
|
220
|
+
const passwordHash = hashAdminModeratorPassword(cleanPassword);
|
|
221
|
+
await executeQuery(
|
|
222
|
+
`INSERT INTO ${TABLES.STICKER_WEB_ADMIN_MODERATOR}
|
|
223
|
+
(google_sub, email, owner_jid, name, password_hash, created_by_google_sub, created_by_email, updated_by_google_sub, updated_by_email, revoked_at)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
225
|
+
ON DUPLICATE KEY UPDATE
|
|
226
|
+
email = VALUES(email),
|
|
227
|
+
owner_jid = VALUES(owner_jid),
|
|
228
|
+
name = VALUES(name),
|
|
229
|
+
password_hash = VALUES(password_hash),
|
|
230
|
+
updated_by_google_sub = VALUES(updated_by_google_sub),
|
|
231
|
+
updated_by_email = VALUES(updated_by_email),
|
|
232
|
+
revoked_at = NULL`,
|
|
233
|
+
[knownUser.google_sub, knownUser.email, knownUser.owner_jid, knownUser.name || null, passwordHash, normalizeGoogleSubject(adminSession?.googleSub) || null, normalizeEmail(adminSession?.email) || null, normalizeGoogleSubject(adminSession?.googleSub) || null, normalizeEmail(adminSession?.email) || null],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
pruneModeratorAdminPanelSessions({
|
|
237
|
+
googleSub: knownUser.google_sub,
|
|
238
|
+
email: knownUser.email,
|
|
239
|
+
ownerJid: knownUser.owner_jid,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const fresh = await findAdminModeratorByGoogleSub(knownUser.google_sub);
|
|
243
|
+
return {
|
|
244
|
+
created: !existing,
|
|
245
|
+
moderator: mapAdminModeratorRow(fresh),
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const revokeAdminModeratorRecord = async (googleSub, adminSession = null) => {
|
|
250
|
+
const normalizedSub = normalizeGoogleSubject(googleSub);
|
|
251
|
+
if (!normalizedSub) {
|
|
252
|
+
const error = new Error('google_sub invalido.');
|
|
253
|
+
error.statusCode = 400;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await executeQuery(
|
|
258
|
+
`UPDATE ${TABLES.STICKER_WEB_ADMIN_MODERATOR}
|
|
259
|
+
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()),
|
|
260
|
+
updated_by_google_sub = ?,
|
|
261
|
+
updated_by_email = ?
|
|
262
|
+
WHERE google_sub = ?`,
|
|
263
|
+
[normalizeGoogleSubject(adminSession?.googleSub) || null, normalizeEmail(adminSession?.email) || null, normalizedSub],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const fresh = await findAdminModeratorByGoogleSub(normalizedSub);
|
|
267
|
+
if (!fresh) {
|
|
268
|
+
const error = new Error('Moderador nao encontrado.');
|
|
269
|
+
error.statusCode = 404;
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
pruneModeratorAdminPanelSessions({
|
|
274
|
+
googleSub: normalizedSub,
|
|
275
|
+
email: normalizeEmail(fresh.email),
|
|
276
|
+
ownerJid: normalizeJid(fresh.owner_jid) || '',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return mapAdminModeratorRow(fresh);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const touchAdminModeratorLastLogin = async (moderatorRow, googleSession) => {
|
|
283
|
+
const normalizedSub = normalizeGoogleSubject(moderatorRow?.google_sub || googleSession?.sub);
|
|
284
|
+
if (!normalizedSub) return;
|
|
285
|
+
await executeQuery(
|
|
286
|
+
`UPDATE ${TABLES.STICKER_WEB_ADMIN_MODERATOR}
|
|
287
|
+
SET email = ?,
|
|
288
|
+
owner_jid = ?,
|
|
289
|
+
name = ?,
|
|
290
|
+
last_login_at = UTC_TIMESTAMP(),
|
|
291
|
+
updated_by_google_sub = ?,
|
|
292
|
+
updated_by_email = ?
|
|
293
|
+
WHERE google_sub = ?`,
|
|
294
|
+
[normalizeEmail(googleSession?.email || moderatorRow?.email) || null, normalizeJid(googleSession?.ownerJid || moderatorRow?.owner_jid) || null, sanitizeText(googleSession?.name || moderatorRow?.name || '', 120, { allowEmpty: true }) || null, normalizeGoogleSubject(googleSession?.sub || moderatorRow?.google_sub) || null, normalizeEmail(googleSession?.email || moderatorRow?.email) || null, normalizedSub],
|
|
295
|
+
).catch(() => {});
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const pruneExpiredAdminPanelSessions = () => {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
if (now - adminPanelSessionPruneAt < 30_000) return;
|
|
301
|
+
adminPanelSessionPruneAt = now;
|
|
302
|
+
for (const [token, session] of adminPanelSessionMap.entries()) {
|
|
303
|
+
if (!session || Number(session.expiresAt || 0) <= now) {
|
|
304
|
+
adminPanelSessionMap.delete(token);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const getAdminPanelSessionTokenFromRequest = (req) => {
|
|
310
|
+
const direct = getCookieValuesFromRequest(req, ADMIN_PANEL_SESSION_COOKIE_NAME);
|
|
311
|
+
if (direct.length > 0) return direct[0];
|
|
312
|
+
const cookies = parseCookies(req);
|
|
313
|
+
return String(cookies[ADMIN_PANEL_SESSION_COOKIE_NAME] || '').trim();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const clearAdminPanelSessionCookie = (req, res) => {
|
|
317
|
+
appendSetCookie(
|
|
318
|
+
res,
|
|
319
|
+
buildCookieString(ADMIN_PANEL_SESSION_COOKIE_NAME, '', req, {
|
|
320
|
+
maxAgeSeconds: 0,
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
// Also clear host-only variant (legacy cookie written without Domain).
|
|
324
|
+
appendSetCookie(
|
|
325
|
+
res,
|
|
326
|
+
buildCookieString(ADMIN_PANEL_SESSION_COOKIE_NAME, '', req, {
|
|
327
|
+
maxAgeSeconds: 0,
|
|
328
|
+
domain: false,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const createAdminPanelSession = (googleSession, { role = 'owner' } = {}) => {
|
|
334
|
+
pruneExpiredAdminPanelSessions();
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
const normalizedRole = normalizeAdminPanelRole(role, 'owner');
|
|
337
|
+
const token = randomUUID();
|
|
338
|
+
const session = {
|
|
339
|
+
token,
|
|
340
|
+
role: normalizedRole,
|
|
341
|
+
googleSub: normalizeGoogleSubject(googleSession?.sub),
|
|
342
|
+
ownerJid: normalizeJid(googleSession?.ownerJid) || '',
|
|
343
|
+
email: normalizeEmail(googleSession?.email),
|
|
344
|
+
name: sanitizeText(googleSession?.name || '', 120, { allowEmpty: true }) || 'Administrador',
|
|
345
|
+
picture: String(googleSession?.picture || '').trim() || '',
|
|
346
|
+
createdAt: now,
|
|
347
|
+
expiresAt: now + ADMIN_PANEL_SESSION_TTL_MS,
|
|
348
|
+
};
|
|
349
|
+
adminPanelSessionMap.set(token, session);
|
|
350
|
+
return session;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const resolveAdminPanelSessionFromRequest = (req) => {
|
|
354
|
+
if (!ADMIN_PANEL_ENABLED) return null;
|
|
355
|
+
pruneExpiredAdminPanelSessions();
|
|
356
|
+
const token = getAdminPanelSessionTokenFromRequest(req);
|
|
357
|
+
if (!token) return null;
|
|
358
|
+
const session = adminPanelSessionMap.get(token);
|
|
359
|
+
if (!session) return null;
|
|
360
|
+
if (Number(session.expiresAt || 0) <= Date.now()) {
|
|
361
|
+
adminPanelSessionMap.delete(token);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
return session;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const mapAdminPanelSessionResponseData = (session) =>
|
|
368
|
+
session
|
|
369
|
+
? {
|
|
370
|
+
authenticated: true,
|
|
371
|
+
role: normalizeAdminPanelRole(session.role, 'owner'),
|
|
372
|
+
capabilities: {
|
|
373
|
+
can_manage_moderators: normalizeAdminPanelRole(session.role, 'owner') === 'owner',
|
|
374
|
+
},
|
|
375
|
+
user: {
|
|
376
|
+
google_sub: session.googleSub,
|
|
377
|
+
owner_jid: session.ownerJid,
|
|
378
|
+
email: session.email,
|
|
379
|
+
name: session.name,
|
|
380
|
+
picture: session.picture || null,
|
|
381
|
+
},
|
|
382
|
+
expires_at: toIsoOrNull(session.expiresAt),
|
|
383
|
+
}
|
|
384
|
+
: {
|
|
385
|
+
authenticated: false,
|
|
386
|
+
role: null,
|
|
387
|
+
capabilities: {
|
|
388
|
+
can_manage_moderators: false,
|
|
389
|
+
},
|
|
390
|
+
user: null,
|
|
391
|
+
expires_at: null,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const isOwnerGoogleSessionAllowed = (googleSession) => {
|
|
395
|
+
if (!ADMIN_PANEL_ENABLED) return false;
|
|
396
|
+
if (!googleSession?.sub || !googleSession?.ownerJid) return false;
|
|
397
|
+
const email = normalizeEmail(googleSession.email);
|
|
398
|
+
return Boolean(email && email === ADMIN_PANEL_EMAIL);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const resolveAdminPanelLoginEligibility = async (googleSession) => {
|
|
402
|
+
if (!ADMIN_PANEL_ENABLED || !googleSession?.sub || !googleSession?.ownerJid) {
|
|
403
|
+
return { eligible: false, role: '', moderator: null };
|
|
404
|
+
}
|
|
405
|
+
if (isOwnerGoogleSessionAllowed(googleSession)) {
|
|
406
|
+
return { eligible: true, role: 'owner', moderator: null };
|
|
407
|
+
}
|
|
408
|
+
const moderator = await findActiveAdminModeratorForIdentity({
|
|
409
|
+
googleSub: googleSession.sub,
|
|
410
|
+
email: googleSession.email,
|
|
411
|
+
ownerJid: googleSession.ownerJid,
|
|
412
|
+
});
|
|
413
|
+
if (!moderator) return { eligible: false, role: '', moderator: null };
|
|
414
|
+
return { eligible: true, role: 'moderator', moderator };
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const requireAdminPanelSession = (req, res) => {
|
|
418
|
+
if (!ADMIN_PANEL_ENABLED) {
|
|
419
|
+
sendJson(req, res, 404, { error: 'Painel admin desabilitado.' });
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const session = resolveAdminPanelSessionFromRequest(req);
|
|
423
|
+
if (!session) {
|
|
424
|
+
sendJson(req, res, 401, { error: 'Sessao admin invalida ou expirada.' });
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
return session;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const requireOwnerAdminPanelSession = (req, res) => {
|
|
431
|
+
const session = requireAdminPanelSession(req, res);
|
|
432
|
+
if (!session) return null;
|
|
433
|
+
if (normalizeAdminPanelRole(session.role, 'owner') !== 'owner') {
|
|
434
|
+
sendJson(req, res, 403, { error: 'Somente o dono pode gerenciar moderadores.' });
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
return session;
|
|
438
|
+
};
|
|
439
|
+
const safeParseJsonObject = (value) => {
|
|
440
|
+
if (!value) return null;
|
|
441
|
+
if (typeof value === 'object') return value;
|
|
442
|
+
try {
|
|
443
|
+
const parsed = JSON.parse(String(value));
|
|
444
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
445
|
+
} catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const sanitizeAuditActionText = (value, max = 96) =>
|
|
451
|
+
String(value || '')
|
|
452
|
+
.trim()
|
|
453
|
+
.toLowerCase()
|
|
454
|
+
.replace(/[^a-z0-9_:-]/g, '_')
|
|
455
|
+
.slice(0, max);
|
|
456
|
+
|
|
457
|
+
const createAdminActionAuditEvent = async ({ adminSession = null, action = '', targetType = '', targetId = '', status = 'success', details = null } = {}) => {
|
|
458
|
+
const normalizedAction = sanitizeAuditActionText(action, 96);
|
|
459
|
+
if (!normalizedAction) return false;
|
|
460
|
+
const normalizedTargetType = sanitizeAuditActionText(targetType, 64) || null;
|
|
461
|
+
const normalizedStatus = sanitizeAuditActionText(status, 32) || 'success';
|
|
462
|
+
const detailsJson = details && typeof details === 'object' ? JSON.stringify(details) : null;
|
|
463
|
+
const adminRole = normalizeAdminPanelRole(adminSession?.role, 'owner');
|
|
464
|
+
const adminGoogleSub = normalizeGoogleSubject(adminSession?.googleSub) || null;
|
|
465
|
+
const adminEmail = normalizeEmail(adminSession?.email) || null;
|
|
466
|
+
const adminOwnerJid = normalizeJid(adminSession?.ownerJid) || null;
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
await executeQuery(
|
|
470
|
+
`INSERT INTO ${TABLES.ADMIN_ACTION_AUDIT}
|
|
471
|
+
(
|
|
472
|
+
id,
|
|
473
|
+
admin_role,
|
|
474
|
+
admin_google_sub,
|
|
475
|
+
admin_email,
|
|
476
|
+
admin_owner_jid,
|
|
477
|
+
action,
|
|
478
|
+
target_type,
|
|
479
|
+
target_id,
|
|
480
|
+
status,
|
|
481
|
+
details
|
|
482
|
+
)
|
|
483
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
484
|
+
[randomUUID(), adminRole, adminGoogleSub, adminEmail, adminOwnerJid, normalizedAction, normalizedTargetType, sanitizeText(targetId || '', 255, { allowEmpty: true }) || null, normalizedStatus, detailsJson],
|
|
485
|
+
);
|
|
486
|
+
return true;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') return false;
|
|
489
|
+
logger.warn('Falha ao registrar auditoria admin.', {
|
|
490
|
+
action: 'admin_audit_insert_failed',
|
|
491
|
+
error: error?.message,
|
|
492
|
+
audit_action: normalizedAction,
|
|
493
|
+
});
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const listAdminAuditLog = async ({ limit = 80 } = {}) => {
|
|
499
|
+
const safeLimit = Math.max(1, Math.min(500, Number(limit || 80)));
|
|
500
|
+
try {
|
|
501
|
+
const rows = await executeQuery(
|
|
502
|
+
`SELECT
|
|
503
|
+
id,
|
|
504
|
+
admin_role,
|
|
505
|
+
admin_google_sub,
|
|
506
|
+
admin_email,
|
|
507
|
+
admin_owner_jid,
|
|
508
|
+
action,
|
|
509
|
+
target_type,
|
|
510
|
+
target_id,
|
|
511
|
+
status,
|
|
512
|
+
details,
|
|
513
|
+
created_at
|
|
514
|
+
FROM ${TABLES.ADMIN_ACTION_AUDIT}
|
|
515
|
+
ORDER BY created_at DESC
|
|
516
|
+
LIMIT ${safeLimit}`,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
520
|
+
id: String(row?.id || '').trim(),
|
|
521
|
+
admin_role: normalizeAdminPanelRole(row?.admin_role, 'owner'),
|
|
522
|
+
admin_google_sub: normalizeGoogleSubject(row?.admin_google_sub),
|
|
523
|
+
admin_email: normalizeEmail(row?.admin_email) || null,
|
|
524
|
+
admin_owner_jid: normalizeJid(row?.admin_owner_jid) || null,
|
|
525
|
+
action: String(row?.action || '').trim(),
|
|
526
|
+
target_type: String(row?.target_type || '').trim() || null,
|
|
527
|
+
target_id: String(row?.target_id || '').trim() || null,
|
|
528
|
+
status: String(row?.status || '').trim() || 'success',
|
|
529
|
+
details: safeParseJsonObject(row?.details),
|
|
530
|
+
created_at: toIsoOrNull(row?.created_at),
|
|
531
|
+
}));
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') return [];
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const listAdminFeatureFlagsDetailed = async ({ limit = 300 } = {}) => {
|
|
539
|
+
const safeLimit = Math.max(1, Math.min(500, Number(limit || 300)));
|
|
540
|
+
try {
|
|
541
|
+
const rows = await executeQuery(
|
|
542
|
+
`SELECT
|
|
543
|
+
flag_name,
|
|
544
|
+
is_enabled,
|
|
545
|
+
rollout_percent,
|
|
546
|
+
description,
|
|
547
|
+
updated_by,
|
|
548
|
+
updated_at
|
|
549
|
+
FROM ${TABLES.FEATURE_FLAG}
|
|
550
|
+
ORDER BY flag_name ASC
|
|
551
|
+
LIMIT ${safeLimit}`,
|
|
552
|
+
);
|
|
553
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
554
|
+
flag_name: sanitizeText(row?.flag_name || '', 120, { allowEmpty: false }) || '',
|
|
555
|
+
is_enabled: Number(row?.is_enabled || 0) === 1,
|
|
556
|
+
rollout_percent: Math.max(0, Math.min(100, Number(row?.rollout_percent || 0))),
|
|
557
|
+
description: sanitizeText(row?.description || '', 255, { allowEmpty: true }) || null,
|
|
558
|
+
updated_by: sanitizeText(row?.updated_by || '', 120, { allowEmpty: true }) || null,
|
|
559
|
+
updated_at: toIsoOrNull(row?.updated_at),
|
|
560
|
+
}));
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') {
|
|
563
|
+
const fallback = await getFeatureFlagsSnapshot().catch(() => []);
|
|
564
|
+
return (Array.isArray(fallback) ? fallback : []).map((entry) => ({
|
|
565
|
+
flag_name: sanitizeText(entry?.flag_name || '', 120, { allowEmpty: false }) || '',
|
|
566
|
+
is_enabled: Boolean(entry?.is_enabled),
|
|
567
|
+
rollout_percent: Math.max(0, Math.min(100, Number(entry?.rollout_percent || 0))),
|
|
568
|
+
description: null,
|
|
569
|
+
updated_by: null,
|
|
570
|
+
updated_at: null,
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const upsertAdminFeatureFlagRecord = async ({ adminSession = null, flagName = '', isEnabled = false, rolloutPercent = 100, description = '' } = {}) => {
|
|
578
|
+
const normalizedFlagName = sanitizeAuditActionText(flagName, 120);
|
|
579
|
+
if (!normalizedFlagName) {
|
|
580
|
+
const error = new Error('flag_name invalido.');
|
|
581
|
+
error.statusCode = 400;
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
const normalizedRollout = Math.max(0, Math.min(100, Math.floor(Number(rolloutPercent) || 0)));
|
|
585
|
+
const normalizedEnabled = isEnabled ? 1 : 0;
|
|
586
|
+
const normalizedDescription = sanitizeText(description || '', 255, { allowEmpty: true }) || null;
|
|
587
|
+
const updatedBy = normalizeEmail(adminSession?.email) || normalizeGoogleSubject(adminSession?.googleSub) || 'admin';
|
|
588
|
+
|
|
589
|
+
await executeQuery(
|
|
590
|
+
`INSERT INTO ${TABLES.FEATURE_FLAG}
|
|
591
|
+
(flag_name, is_enabled, rollout_percent, description, updated_by)
|
|
592
|
+
VALUES (?, ?, ?, ?, ?)
|
|
593
|
+
ON DUPLICATE KEY UPDATE
|
|
594
|
+
is_enabled = VALUES(is_enabled),
|
|
595
|
+
rollout_percent = VALUES(rollout_percent),
|
|
596
|
+
description = COALESCE(VALUES(description), description),
|
|
597
|
+
updated_by = VALUES(updated_by),
|
|
598
|
+
updated_at = CURRENT_TIMESTAMP`,
|
|
599
|
+
[normalizedFlagName, normalizedEnabled, normalizedRollout, normalizedDescription, sanitizeText(updatedBy, 120, { allowEmpty: true }) || null],
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
await refreshFeatureFlags({ force: true }).catch(() => {});
|
|
603
|
+
const rows = await executeQuery(
|
|
604
|
+
`SELECT flag_name, is_enabled, rollout_percent, description, updated_by, updated_at
|
|
605
|
+
FROM ${TABLES.FEATURE_FLAG}
|
|
606
|
+
WHERE flag_name = ?
|
|
607
|
+
LIMIT 1`,
|
|
608
|
+
[normalizedFlagName],
|
|
609
|
+
);
|
|
610
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
611
|
+
return {
|
|
612
|
+
flag_name: sanitizeText(row?.flag_name || normalizedFlagName, 120, { allowEmpty: false }) || normalizedFlagName,
|
|
613
|
+
is_enabled: Number(row?.is_enabled || 0) === 1,
|
|
614
|
+
rollout_percent: Math.max(0, Math.min(100, Number(row?.rollout_percent ?? normalizedRollout))),
|
|
615
|
+
description: sanitizeText(row?.description || '', 255, { allowEmpty: true }) || null,
|
|
616
|
+
updated_by: sanitizeText(row?.updated_by || '', 120, { allowEmpty: true }) || null,
|
|
617
|
+
updated_at: toIsoOrNull(row?.updated_at) || new Date().toISOString(),
|
|
618
|
+
};
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const getAdminMessageFlowDailyStats = async () => {
|
|
622
|
+
try {
|
|
623
|
+
const [row] = await executeQuery(
|
|
624
|
+
`SELECT
|
|
625
|
+
COUNT(*) AS messages_today,
|
|
626
|
+
SUM(CASE WHEN processing_result = 'blocked_antilink' THEN 1 ELSE 0 END) AS spam_blocked_today,
|
|
627
|
+
SUM(CASE WHEN processing_result = 'auth_required' THEN 1 ELSE 0 END) AS suspicious_today
|
|
628
|
+
FROM ${TABLES.MESSAGE_ANALYSIS_EVENT}
|
|
629
|
+
WHERE created_at >= UTC_DATE()`,
|
|
630
|
+
);
|
|
631
|
+
return {
|
|
632
|
+
messages_today: Number(row?.messages_today || 0),
|
|
633
|
+
spam_blocked_today: Number(row?.spam_blocked_today || 0),
|
|
634
|
+
suspicious_today: Number(row?.suspicious_today || 0),
|
|
635
|
+
available: true,
|
|
636
|
+
};
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') {
|
|
639
|
+
return {
|
|
640
|
+
messages_today: null,
|
|
641
|
+
spam_blocked_today: null,
|
|
642
|
+
suspicious_today: null,
|
|
643
|
+
available: false,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const listRecentModerationEvents = async ({ limit = 40 } = {}) => {
|
|
651
|
+
const safeLimit = Math.max(1, Math.min(200, Number(limit || 40)));
|
|
652
|
+
try {
|
|
653
|
+
const rows = await executeQuery(
|
|
654
|
+
`SELECT
|
|
655
|
+
id,
|
|
656
|
+
message_id,
|
|
657
|
+
chat_id,
|
|
658
|
+
sender_id,
|
|
659
|
+
sender_name,
|
|
660
|
+
processing_result,
|
|
661
|
+
command_name,
|
|
662
|
+
error_code,
|
|
663
|
+
metadata,
|
|
664
|
+
created_at
|
|
665
|
+
FROM ${TABLES.MESSAGE_ANALYSIS_EVENT}
|
|
666
|
+
WHERE processing_result IN ('blocked_antilink', 'auth_required')
|
|
667
|
+
ORDER BY created_at DESC
|
|
668
|
+
LIMIT ${safeLimit}`,
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
return (Array.isArray(rows) ? rows : []).map((row) => {
|
|
672
|
+
const processingResult = String(row?.processing_result || '')
|
|
673
|
+
.trim()
|
|
674
|
+
.toLowerCase();
|
|
675
|
+
const metadata = safeParseJsonObject(row?.metadata);
|
|
676
|
+
const isAntiLink = processingResult === 'blocked_antilink';
|
|
677
|
+
const title = isAntiLink ? 'Anti-link bloqueou mensagem' : 'Tentativa suspeita detectada';
|
|
678
|
+
const severity = isAntiLink ? 'medium' : 'high';
|
|
679
|
+
const sender = sanitizeText(row?.sender_name || row?.sender_id || '', 120, { allowEmpty: true }) || String(row?.sender_id || '').trim() || 'desconhecido';
|
|
680
|
+
const chatId = String(row?.chat_id || '').trim() || 'chat_desconhecido';
|
|
681
|
+
return {
|
|
682
|
+
id: `mae:${row?.id || ''}`,
|
|
683
|
+
event_type: isAntiLink ? 'anti_link' : 'suspicious',
|
|
684
|
+
severity,
|
|
685
|
+
title,
|
|
686
|
+
subtitle: `${sender} em ${chatId}`,
|
|
687
|
+
chat_id: chatId,
|
|
688
|
+
sender_id: String(row?.sender_id || '').trim() || null,
|
|
689
|
+
sender_name: sanitizeText(row?.sender_name || '', 120, { allowEmpty: true }) || null,
|
|
690
|
+
message_id: String(row?.message_id || '').trim() || null,
|
|
691
|
+
processing_result: processingResult,
|
|
692
|
+
command_name: sanitizeText(row?.command_name || '', 64, { allowEmpty: true }) || null,
|
|
693
|
+
error_code: sanitizeText(row?.error_code || '', 96, { allowEmpty: true }) || null,
|
|
694
|
+
metadata,
|
|
695
|
+
created_at: toIsoOrNull(row?.created_at),
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') return [];
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const buildModerationQueueSnapshot = async ({ limit = 50 } = {}) => {
|
|
705
|
+
const [analysisEvents, bans] = await Promise.all([listRecentModerationEvents({ limit: Math.max(10, limit) }), listAdminBans({ activeOnly: false, limit: Math.max(10, Math.floor(limit / 2)) })]);
|
|
706
|
+
|
|
707
|
+
const banEvents = (Array.isArray(bans) ? bans : []).map((ban) => ({
|
|
708
|
+
id: `ban:${ban?.id || ''}`,
|
|
709
|
+
event_type: 'ban',
|
|
710
|
+
severity: ban?.revoked_at ? 'low' : 'critical',
|
|
711
|
+
title: ban?.revoked_at ? 'Ban revogado' : 'Conta bloqueada',
|
|
712
|
+
subtitle: sanitizeText(ban?.email || ban?.owner_jid || ban?.google_sub || '', 160, { allowEmpty: true }) || 'identidade indisponivel',
|
|
713
|
+
ban_id: String(ban?.id || '').trim(),
|
|
714
|
+
reason: sanitizeText(ban?.reason || '', 255, { allowEmpty: true }) || null,
|
|
715
|
+
created_at: toIsoOrNull(ban?.created_at),
|
|
716
|
+
revoked_at: toIsoOrNull(ban?.revoked_at),
|
|
717
|
+
metadata: {
|
|
718
|
+
google_sub: ban?.google_sub || null,
|
|
719
|
+
email: ban?.email || null,
|
|
720
|
+
owner_jid: ban?.owner_jid || null,
|
|
721
|
+
},
|
|
722
|
+
}));
|
|
723
|
+
|
|
724
|
+
const combined = [...(Array.isArray(analysisEvents) ? analysisEvents : []), ...banEvents];
|
|
725
|
+
combined.sort((left, right) => {
|
|
726
|
+
const leftTs = Date.parse(String(left?.created_at || left?.revoked_at || 0)) || 0;
|
|
727
|
+
const rightTs = Date.parse(String(right?.created_at || right?.revoked_at || 0)) || 0;
|
|
728
|
+
return rightTs - leftTs;
|
|
729
|
+
});
|
|
730
|
+
return combined.slice(0, Math.max(1, Math.min(200, Number(limit || 50))));
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
const buildAdminSystemHealthSnapshot = ({ systemSummary = null, systemMeta = null } = {}) => {
|
|
734
|
+
const hostCpu = Number(systemSummary?.host?.cpu_percent);
|
|
735
|
+
const hostRam = Number(systemSummary?.host?.memory_percent);
|
|
736
|
+
const latencyP95 = Number(systemSummary?.observability?.http_latency_p95_ms);
|
|
737
|
+
const queuePending = Number(systemSummary?.observability?.queue_peak);
|
|
738
|
+
const hasMetricsError = Boolean(systemMeta?.metrics_error);
|
|
739
|
+
const hasPlatformError = Boolean(systemMeta?.platform_error);
|
|
740
|
+
const dbStatus = hasPlatformError ? 'degraded' : hasMetricsError ? 'unknown' : 'ok';
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
cpu_percent: Number.isFinite(hostCpu) ? hostCpu : null,
|
|
744
|
+
ram_percent: Number.isFinite(hostRam) ? hostRam : null,
|
|
745
|
+
http_latency_p95_ms: Number.isFinite(latencyP95) ? latencyP95 : null,
|
|
746
|
+
queue_pending: Number.isFinite(queuePending) ? queuePending : null,
|
|
747
|
+
db_status: dbStatus,
|
|
748
|
+
db_total_queries: Number(systemSummary?.observability?.db_total ?? 0) || 0,
|
|
749
|
+
db_slow_queries: Number(systemSummary?.observability?.db_slow ?? 0) || 0,
|
|
750
|
+
bot_status: String(systemSummary?.bot?.connection_status || '').trim() || 'unknown',
|
|
751
|
+
updated_at: toIsoOrNull(systemSummary?.updated_at),
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const buildAdminAlertSnapshot = ({ dashboardQuick = null, systemHealth = null, systemSummary = null, systemMeta = null } = {}) => {
|
|
756
|
+
const alerts = [];
|
|
757
|
+
const updatedAt = toIsoOrNull(systemSummary?.updated_at) || new Date().toISOString();
|
|
758
|
+
const pushAlert = (severity, code, title, message) => {
|
|
759
|
+
alerts.push({
|
|
760
|
+
id: `${code}:${severity}`,
|
|
761
|
+
severity,
|
|
762
|
+
code,
|
|
763
|
+
title,
|
|
764
|
+
message,
|
|
765
|
+
created_at: updatedAt,
|
|
766
|
+
});
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const botStatus = String(systemSummary?.bot?.connection_status || '').toLowerCase();
|
|
770
|
+
if (botStatus && botStatus !== 'online') {
|
|
771
|
+
pushAlert('critical', 'bot_offline', 'Bot fora do ar', `Status atual: ${botStatus}.`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (Number.isFinite(systemHealth?.cpu_percent) && systemHealth.cpu_percent >= 90) {
|
|
775
|
+
pushAlert('high', 'cpu_high', 'CPU alta', `Uso de CPU em ${systemHealth.cpu_percent.toFixed(1)}%.`);
|
|
776
|
+
}
|
|
777
|
+
if (Number.isFinite(systemHealth?.ram_percent) && systemHealth.ram_percent >= 90) {
|
|
778
|
+
pushAlert('high', 'ram_high', 'RAM alta', `Uso de RAM em ${systemHealth.ram_percent.toFixed(1)}%.`);
|
|
779
|
+
}
|
|
780
|
+
if (Number.isFinite(systemHealth?.queue_pending) && systemHealth.queue_pending >= 100) {
|
|
781
|
+
pushAlert('medium', 'queue_high', 'Fila pendente alta', `Backlog detectado (${Math.round(systemHealth.queue_pending)}).`);
|
|
782
|
+
}
|
|
783
|
+
if (Number.isFinite(dashboardQuick?.errors_5xx) && dashboardQuick.errors_5xx > 0) {
|
|
784
|
+
pushAlert('medium', 'http_5xx', 'Erros HTTP 5xx detectados', `${Math.round(dashboardQuick.errors_5xx)} eventos 5xx desde o boot de métricas.`);
|
|
785
|
+
}
|
|
786
|
+
if (systemMeta?.platform_error) {
|
|
787
|
+
pushAlert('high', 'db_platform_error', 'Erro de banco/plataforma', String(systemMeta.platform_error).slice(0, 200));
|
|
788
|
+
}
|
|
789
|
+
if (systemMeta?.metrics_error) {
|
|
790
|
+
pushAlert('low', 'metrics_unavailable', 'Métricas indisponíveis', String(systemMeta.metrics_error).slice(0, 200));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return alerts;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const listAdminActiveGoogleWebSessions = async ({ limit = 200 } = {}) => {
|
|
797
|
+
const safeLimit = Math.max(1, Math.min(500, Number(limit || 200)));
|
|
798
|
+
const rows = await executeQuery(
|
|
799
|
+
`SELECT session_token, google_sub, owner_jid, owner_phone, email, name, picture_url, created_at, last_seen_at, expires_at
|
|
800
|
+
FROM ${TABLES.STICKER_WEB_GOOGLE_SESSION}
|
|
801
|
+
WHERE revoked_at IS NULL
|
|
802
|
+
AND expires_at > UTC_TIMESTAMP()
|
|
803
|
+
ORDER BY COALESCE(last_seen_at, created_at) DESC
|
|
804
|
+
LIMIT ${safeLimit}`,
|
|
805
|
+
);
|
|
806
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
807
|
+
session_token: String(row.session_token || '').trim(),
|
|
808
|
+
google_sub: normalizeGoogleSubject(row.google_sub),
|
|
809
|
+
owner_jid: normalizeJid(row.owner_jid) || null,
|
|
810
|
+
owner_phone: toWhatsAppPhoneDigits(row.owner_phone || row.owner_jid) || null,
|
|
811
|
+
email: normalizeEmail(row.email) || null,
|
|
812
|
+
name: sanitizeText(row.name || '', 120, { allowEmpty: true }) || null,
|
|
813
|
+
picture: String(row.picture_url || '').trim() || null,
|
|
814
|
+
created_at: toIsoOrNull(row.created_at),
|
|
815
|
+
last_seen_at: toIsoOrNull(row.last_seen_at),
|
|
816
|
+
expires_at: toIsoOrNull(row.expires_at),
|
|
817
|
+
}));
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const listAdminKnownGoogleUsers = async ({ limit = 200 } = {}) => {
|
|
821
|
+
const safeLimit = Math.max(1, Math.min(500, Number(limit || 200)));
|
|
822
|
+
const rows = await executeQuery(
|
|
823
|
+
`SELECT google_sub, owner_jid, owner_phone, email, name, picture_url, created_at, updated_at, last_login_at, last_seen_at
|
|
824
|
+
FROM ${TABLES.STICKER_WEB_GOOGLE_USER}
|
|
825
|
+
ORDER BY COALESCE(last_seen_at, last_login_at, updated_at, created_at) DESC
|
|
826
|
+
LIMIT ${safeLimit}`,
|
|
827
|
+
);
|
|
828
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
829
|
+
google_sub: normalizeGoogleSubject(row.google_sub),
|
|
830
|
+
owner_jid: normalizeJid(row.owner_jid) || null,
|
|
831
|
+
owner_phone: toWhatsAppPhoneDigits(row.owner_phone || row.owner_jid) || null,
|
|
832
|
+
email: normalizeEmail(row.email) || null,
|
|
833
|
+
name: sanitizeText(row.name || '', 120, { allowEmpty: true }) || null,
|
|
834
|
+
picture: String(row.picture_url || '').trim() || null,
|
|
835
|
+
created_at: toIsoOrNull(row.created_at),
|
|
836
|
+
updated_at: toIsoOrNull(row.updated_at),
|
|
837
|
+
last_login_at: toIsoOrNull(row.last_login_at),
|
|
838
|
+
last_seen_at: toIsoOrNull(row.last_seen_at),
|
|
839
|
+
}));
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const getWebVisitSummary = async ({ rangeDays = 7, topPathsLimit = 10 } = {}) => {
|
|
843
|
+
const safeRangeDays = Math.max(1, Math.min(90, Number(rangeDays || 7)));
|
|
844
|
+
const safeTopPathsLimit = Math.max(1, Math.min(30, Number(topPathsLimit || 10)));
|
|
845
|
+
|
|
846
|
+
try {
|
|
847
|
+
const [countersRows, topPathsRows] = await Promise.all([
|
|
848
|
+
executeQuery(
|
|
849
|
+
`SELECT
|
|
850
|
+
COUNT(*) AS total_events,
|
|
851
|
+
SUM(CASE WHEN created_at >= (UTC_TIMESTAMP() - INTERVAL 1 DAY) THEN 1 ELSE 0 END) AS events_24h,
|
|
852
|
+
SUM(CASE WHEN created_at >= (UTC_TIMESTAMP() - INTERVAL ${safeRangeDays} DAY) THEN 1 ELSE 0 END) AS events_range,
|
|
853
|
+
COUNT(DISTINCT CASE WHEN created_at >= (UTC_TIMESTAMP() - INTERVAL ${safeRangeDays} DAY) THEN visitor_key END) AS unique_visitors_range,
|
|
854
|
+
COUNT(DISTINCT CASE WHEN created_at >= (UTC_TIMESTAMP() - INTERVAL ${safeRangeDays} DAY) THEN session_key END) AS unique_sessions_range
|
|
855
|
+
FROM ${TABLES.WEB_VISIT_EVENT}`,
|
|
856
|
+
),
|
|
857
|
+
executeQuery(
|
|
858
|
+
`SELECT page_path, COUNT(*) AS total
|
|
859
|
+
FROM ${TABLES.WEB_VISIT_EVENT}
|
|
860
|
+
WHERE created_at >= (UTC_TIMESTAMP() - INTERVAL ${safeRangeDays} DAY)
|
|
861
|
+
GROUP BY page_path
|
|
862
|
+
ORDER BY total DESC
|
|
863
|
+
LIMIT ${safeTopPathsLimit}`,
|
|
864
|
+
),
|
|
865
|
+
]);
|
|
866
|
+
|
|
867
|
+
const counters = Array.isArray(countersRows) ? countersRows[0] || {} : {};
|
|
868
|
+
const topPaths = (Array.isArray(topPathsRows) ? topPathsRows : []).map((row) => ({
|
|
869
|
+
page_path: normalizeVisitPath(row?.page_path || '/'),
|
|
870
|
+
total: Number(row?.total || 0),
|
|
871
|
+
}));
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
range_days: safeRangeDays,
|
|
875
|
+
total_events: Number(counters?.total_events || 0),
|
|
876
|
+
events_24h: Number(counters?.events_24h || 0),
|
|
877
|
+
events_range: Number(counters?.events_range || 0),
|
|
878
|
+
unique_visitors_range: Number(counters?.unique_visitors_range || 0),
|
|
879
|
+
unique_sessions_range: Number(counters?.unique_sessions_range || 0),
|
|
880
|
+
top_paths: topPaths,
|
|
881
|
+
};
|
|
882
|
+
} catch (error) {
|
|
883
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') return null;
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
const listAdminPacks = async (url) => {
|
|
889
|
+
const q = sanitizeText(url?.searchParams?.get('q') || '', 120, { allowEmpty: true }) || '';
|
|
890
|
+
const owner = normalizeJid(url?.searchParams?.get('owner_jid') || '') || '';
|
|
891
|
+
const limit = Math.max(1, Math.min(200, Number(url?.searchParams?.get('limit') || 50)));
|
|
892
|
+
const params = [];
|
|
893
|
+
const where = ['p.deleted_at IS NULL'];
|
|
894
|
+
if (q) {
|
|
895
|
+
where.push('(p.pack_key LIKE ? OR p.name LIKE ? OR p.publisher LIKE ? OR p.owner_jid LIKE ?)');
|
|
896
|
+
const like = `%${q}%`;
|
|
897
|
+
params.push(like, like, like, like);
|
|
898
|
+
}
|
|
899
|
+
if (owner) {
|
|
900
|
+
where.push('p.owner_jid = ?');
|
|
901
|
+
params.push(owner);
|
|
902
|
+
}
|
|
903
|
+
const rows = await executeQuery(
|
|
904
|
+
`SELECT
|
|
905
|
+
p.id,
|
|
906
|
+
p.pack_key,
|
|
907
|
+
p.owner_jid,
|
|
908
|
+
p.name,
|
|
909
|
+
p.publisher,
|
|
910
|
+
p.visibility,
|
|
911
|
+
p.status,
|
|
912
|
+
p.pack_status,
|
|
913
|
+
p.is_auto_pack,
|
|
914
|
+
p.pack_theme_key,
|
|
915
|
+
p.pack_volume,
|
|
916
|
+
p.created_at,
|
|
917
|
+
p.updated_at,
|
|
918
|
+
p.cover_sticker_id,
|
|
919
|
+
(SELECT COUNT(*) FROM ${TABLES.STICKER_PACK_ITEM} i WHERE i.pack_id = p.id) AS stickers_count,
|
|
920
|
+
COALESCE(e.open_count, 0) AS open_count,
|
|
921
|
+
COALESCE(e.like_count, 0) AS like_count,
|
|
922
|
+
COALESCE(e.dislike_count, 0) AS dislike_count
|
|
923
|
+
FROM ${TABLES.STICKER_PACK} p
|
|
924
|
+
LEFT JOIN ${TABLES.STICKER_PACK_ENGAGEMENT} e ON e.pack_id = p.id
|
|
925
|
+
WHERE ${where.join(' AND ')}
|
|
926
|
+
ORDER BY p.updated_at DESC
|
|
927
|
+
LIMIT ${limit}`,
|
|
928
|
+
params,
|
|
929
|
+
);
|
|
930
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
931
|
+
id: String(row.id || ''),
|
|
932
|
+
pack_key: String(row.pack_key || ''),
|
|
933
|
+
owner_jid: normalizeJid(row.owner_jid) || null,
|
|
934
|
+
name: String(row.name || ''),
|
|
935
|
+
publisher: String(row.publisher || ''),
|
|
936
|
+
visibility: String(row.visibility || ''),
|
|
937
|
+
status: String(row.status || ''),
|
|
938
|
+
pack_status: String(row.pack_status || 'ready'),
|
|
939
|
+
is_auto_pack: Boolean(Number(row.is_auto_pack || 0)),
|
|
940
|
+
pack_theme_key: String(row.pack_theme_key || '').trim() || null,
|
|
941
|
+
pack_volume: Number.isFinite(Number(row.pack_volume)) ? Number(row.pack_volume) : null,
|
|
942
|
+
created_at: toIsoOrNull(row.created_at),
|
|
943
|
+
updated_at: toIsoOrNull(row.updated_at),
|
|
944
|
+
cover_sticker_id: String(row.cover_sticker_id || '').trim() || null,
|
|
945
|
+
stickers_count: Number(row.stickers_count || 0),
|
|
946
|
+
open_count: Number(row.open_count || 0),
|
|
947
|
+
like_count: Number(row.like_count || 0),
|
|
948
|
+
dislike_count: Number(row.dislike_count || 0),
|
|
949
|
+
web_url: `${STICKER_WEB_PATH}/${encodeURIComponent(String(row.pack_key || ''))}`,
|
|
950
|
+
}));
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const buildAdminOverviewPayload = async ({ adminSession = null } = {}) => {
|
|
954
|
+
const [marketplaceStats, activeSessions, knownUsers, bans, packsCountRows, stickersCountRows, recentPacks, visitSummary, systemSummaryPayload, messageFlowDaily, moderationQueue, auditLog, featureFlags] = await Promise.all([getMarketplaceGlobalStatsCached().catch(() => null), listAdminActiveGoogleWebSessions({ limit: 80 }), listAdminKnownGoogleUsers({ limit: 120 }), listAdminBans({ activeOnly: true, limit: 120 }), executeQuery(`SELECT COUNT(*) AS total FROM ${TABLES.STICKER_PACK} WHERE deleted_at IS NULL`), executeQuery(`SELECT COUNT(*) AS total FROM ${TABLES.STICKER_ASSET}`), listAdminPacks({ searchParams: new URLSearchParams([['limit', '30']]) }), getWebVisitSummary({ rangeDays: 7, topPathsLimit: 10 }).catch(() => null), getSystemSummaryCached().catch(() => null), getAdminMessageFlowDailyStats().catch(() => ({ messages_today: null, spam_blocked_today: null, suspicious_today: null, available: false })), buildModerationQueueSnapshot({ limit: 80 }).catch(() => []), listAdminAuditLog({ limit: 120 }).catch(() => []), listAdminFeatureFlagsDetailed({ limit: 300 }).catch(() => [])]);
|
|
955
|
+
|
|
956
|
+
const systemSummary = systemSummaryPayload?.data || null;
|
|
957
|
+
const systemMeta = systemSummaryPayload?.meta || null;
|
|
958
|
+
const botsOnline = systemSummary?.bot?.connected ? 1 : 0;
|
|
959
|
+
const errors5xx = Number(systemSummary?.observability?.http_5xx_total ?? 0);
|
|
960
|
+
const dashboardQuick = {
|
|
961
|
+
bots_online: botsOnline,
|
|
962
|
+
messages_today: Number(messageFlowDaily?.messages_today ?? 0),
|
|
963
|
+
spam_blocked_today: Number(messageFlowDaily?.spam_blocked_today ?? 0),
|
|
964
|
+
uptime: String(systemSummary?.process?.uptime || '').trim() || 'n/d',
|
|
965
|
+
errors_5xx: Number.isFinite(errors5xx) ? Math.max(0, errors5xx) : 0,
|
|
966
|
+
};
|
|
967
|
+
const systemHealth = buildAdminSystemHealthSnapshot({ systemSummary, systemMeta });
|
|
968
|
+
const alerts = buildAdminAlertSnapshot({ dashboardQuick, systemHealth, systemSummary, systemMeta });
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
admin_session: mapAdminPanelSessionResponseData(adminSession),
|
|
972
|
+
marketplace_stats: marketplaceStats,
|
|
973
|
+
counters: {
|
|
974
|
+
total_packs_any_status: Number(packsCountRows?.[0]?.total || 0),
|
|
975
|
+
total_stickers_any_status: Number(stickersCountRows?.[0]?.total || 0),
|
|
976
|
+
active_google_sessions: Number(activeSessions.length || 0),
|
|
977
|
+
known_google_users: Number(knownUsers.length || 0),
|
|
978
|
+
active_bans: Number(bans.length || 0),
|
|
979
|
+
visit_events_24h: Number(visitSummary?.events_24h || 0),
|
|
980
|
+
visit_events_7d: Number(visitSummary?.events_range || 0),
|
|
981
|
+
unique_visitors_7d: Number(visitSummary?.unique_visitors_range || 0),
|
|
982
|
+
},
|
|
983
|
+
dashboard_quick: dashboardQuick,
|
|
984
|
+
moderation_queue: moderationQueue,
|
|
985
|
+
users_sessions: {
|
|
986
|
+
active_sessions: activeSessions,
|
|
987
|
+
users: knownUsers,
|
|
988
|
+
blocked_accounts: bans,
|
|
989
|
+
},
|
|
990
|
+
system_health: systemHealth,
|
|
991
|
+
audit_log: auditLog,
|
|
992
|
+
feature_flags: featureFlags,
|
|
993
|
+
alerts,
|
|
994
|
+
operational_shortcuts: [
|
|
995
|
+
{ action: 'restart_worker', label: 'Reiniciar worker', description: 'Destrava filas em processamento e recoloca em pending.' },
|
|
996
|
+
{ action: 'clear_cache', label: 'Limpar cache', description: 'Invalida caches internos de catálogo, ranking e resumo.' },
|
|
997
|
+
{ action: 'reprocess_jobs', label: 'Reprocessar jobs', description: 'Agenda ciclos de classificação/curadoria no worker.' },
|
|
998
|
+
],
|
|
999
|
+
active_sessions: activeSessions,
|
|
1000
|
+
users: knownUsers,
|
|
1001
|
+
bans,
|
|
1002
|
+
recent_packs: recentPacks,
|
|
1003
|
+
visit_metrics: visitSummary,
|
|
1004
|
+
system_summary: systemSummary,
|
|
1005
|
+
system_meta: systemMeta,
|
|
1006
|
+
message_flow_daily: messageFlowDaily,
|
|
1007
|
+
updated_at: new Date().toISOString(),
|
|
1008
|
+
};
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
const findAdminPackContextByKey = async (rawPackKey) => {
|
|
1012
|
+
const packKey = sanitizeText(rawPackKey, 160, { allowEmpty: false });
|
|
1013
|
+
if (!packKey) return null;
|
|
1014
|
+
const basePack = await findStickerPackByPackKey(packKey);
|
|
1015
|
+
if (!basePack) return null;
|
|
1016
|
+
const ownerJid = normalizeJid(basePack.owner_jid) || '';
|
|
1017
|
+
if (!ownerJid) return null;
|
|
1018
|
+
const fullPack = await stickerPackService.getPackInfo({ ownerJid, identifier: basePack.pack_key });
|
|
1019
|
+
return { basePack, fullPack, ownerJid, packKey: basePack.pack_key };
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const handleAdminPanelSessionRequest = async (req, res) => {
|
|
1023
|
+
if (!ADMIN_PANEL_ENABLED) {
|
|
1024
|
+
sendJson(req, res, 404, { error: 'Painel admin desabilitado.' });
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1029
|
+
const googleSession = await resolveGoogleWebSessionFromRequest(req);
|
|
1030
|
+
const adminSession = resolveAdminPanelSessionFromRequest(req);
|
|
1031
|
+
const eligibility = await resolveAdminPanelLoginEligibility(googleSession);
|
|
1032
|
+
sendJson(req, res, 200, {
|
|
1033
|
+
data: {
|
|
1034
|
+
google: mapGoogleSessionResponseData(googleSession),
|
|
1035
|
+
eligible_google_login: Boolean(eligibility.eligible),
|
|
1036
|
+
eligible_role: eligibility.role || null,
|
|
1037
|
+
session: mapAdminPanelSessionResponseData(adminSession),
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (req.method === 'DELETE') {
|
|
1044
|
+
const token = getAdminPanelSessionTokenFromRequest(req);
|
|
1045
|
+
const adminSession = resolveAdminPanelSessionFromRequest(req);
|
|
1046
|
+
if (token) adminPanelSessionMap.delete(token);
|
|
1047
|
+
clearAdminPanelSessionCookie(req, res);
|
|
1048
|
+
await createAdminActionAuditEvent({
|
|
1049
|
+
adminSession,
|
|
1050
|
+
action: 'admin_session_logout',
|
|
1051
|
+
targetType: 'admin_session',
|
|
1052
|
+
targetId: token || 'cookie_clear',
|
|
1053
|
+
});
|
|
1054
|
+
sendJson(req, res, 200, { data: { cleared: true } });
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (req.method !== 'POST') {
|
|
1059
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
let payload = {};
|
|
1064
|
+
try {
|
|
1065
|
+
payload = await readJsonBody(req);
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Body inválido.' });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const googleSession = await resolveGoogleWebSessionFromRequest(req);
|
|
1072
|
+
const eligibility = await resolveAdminPanelLoginEligibility(googleSession);
|
|
1073
|
+
if (!eligibility.eligible) {
|
|
1074
|
+
sendJson(req, res, 403, { error: 'Conta Google sem permissao para o painel admin.' });
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const password = String(payload?.password || '').trim();
|
|
1078
|
+
let sessionRole = 'owner';
|
|
1079
|
+
if (eligibility.role === 'owner') {
|
|
1080
|
+
if (!password || !constantTimeStringEqual(password, ADMIN_PANEL_PASSWORD)) {
|
|
1081
|
+
sendJson(req, res, 401, { error: 'Senha do painel admin invalida.' });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
sessionRole = 'owner';
|
|
1085
|
+
} else if (eligibility.role === 'moderator') {
|
|
1086
|
+
const moderatorHash = String(eligibility?.moderator?.password_hash || '').trim();
|
|
1087
|
+
if (!password || !verifyAdminModeratorPassword(password, moderatorHash)) {
|
|
1088
|
+
sendJson(req, res, 401, { error: 'Senha do moderador invalida.' });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
sessionRole = 'moderator';
|
|
1092
|
+
await touchAdminModeratorLastLogin(eligibility.moderator, googleSession).catch(() => {});
|
|
1093
|
+
} else {
|
|
1094
|
+
sendJson(req, res, 403, { error: 'Conta Google sem permissao para o painel admin.' });
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const session = createAdminPanelSession(googleSession, { role: sessionRole });
|
|
1099
|
+
appendSetCookie(
|
|
1100
|
+
res,
|
|
1101
|
+
buildCookieString(ADMIN_PANEL_SESSION_COOKIE_NAME, session.token, req, {
|
|
1102
|
+
maxAgeSeconds: Math.floor(ADMIN_PANEL_SESSION_TTL_MS / 1000),
|
|
1103
|
+
}),
|
|
1104
|
+
);
|
|
1105
|
+
sendJson(req, res, 200, {
|
|
1106
|
+
data: {
|
|
1107
|
+
google: mapGoogleSessionResponseData(googleSession),
|
|
1108
|
+
eligible_google_login: true,
|
|
1109
|
+
eligible_role: sessionRole,
|
|
1110
|
+
session: mapAdminPanelSessionResponseData(session),
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
await createAdminActionAuditEvent({
|
|
1114
|
+
adminSession: session,
|
|
1115
|
+
action: 'admin_session_login',
|
|
1116
|
+
targetType: 'admin_session',
|
|
1117
|
+
targetId: session.token,
|
|
1118
|
+
details: { role: sessionRole },
|
|
1119
|
+
});
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const handleAdminOverviewRequest = async (req, res) => {
|
|
1123
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1124
|
+
if (!adminSession) return;
|
|
1125
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
1126
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const overview = await buildAdminOverviewPayload({ adminSession });
|
|
1131
|
+
sendJson(req, res, 200, { data: overview });
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const handleAdminUsersRequest = async (req, res, url) => {
|
|
1135
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1136
|
+
if (!adminSession) return;
|
|
1137
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
1138
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const limit = Math.max(1, Math.min(500, Number(url?.searchParams?.get('limit') || 200)));
|
|
1142
|
+
const [activeSessions, users] = await Promise.all([listAdminActiveGoogleWebSessions({ limit }), listAdminKnownGoogleUsers({ limit })]);
|
|
1143
|
+
sendJson(req, res, 200, { data: { active_sessions: activeSessions, users } });
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
const handleAdminForceLogoutRequest = async (req, res) => {
|
|
1147
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1148
|
+
if (!adminSession) return;
|
|
1149
|
+
if (req.method !== 'POST') {
|
|
1150
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
let payload = {};
|
|
1155
|
+
try {
|
|
1156
|
+
payload = await readJsonBody(req);
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Body inválido.' });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
let googleSub = normalizeGoogleSubject(payload?.google_sub || '');
|
|
1163
|
+
let email = normalizeEmail(payload?.email || '');
|
|
1164
|
+
let ownerJid = normalizeJid(payload?.owner_jid || '') || '';
|
|
1165
|
+
const sessionToken = sanitizeText(payload?.session_token || '', 36, { allowEmpty: true }) || '';
|
|
1166
|
+
|
|
1167
|
+
if (sessionToken) {
|
|
1168
|
+
const rows = await executeQuery(
|
|
1169
|
+
`SELECT google_sub, email, owner_jid
|
|
1170
|
+
FROM ${TABLES.STICKER_WEB_GOOGLE_SESSION}
|
|
1171
|
+
WHERE session_token = ?
|
|
1172
|
+
LIMIT 1`,
|
|
1173
|
+
[sessionToken],
|
|
1174
|
+
).catch(() => []);
|
|
1175
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
1176
|
+
if (row) {
|
|
1177
|
+
googleSub = normalizeGoogleSubject(row.google_sub || googleSub);
|
|
1178
|
+
email = normalizeEmail(row.email || email);
|
|
1179
|
+
ownerJid = normalizeJid(row.owner_jid || ownerJid) || ownerJid;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (!googleSub && !email && !ownerJid) {
|
|
1184
|
+
sendJson(req, res, 400, { error: 'Informe session_token, google_sub, email ou owner_jid.' });
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const removed = await revokeGoogleWebSessionsByIdentity({
|
|
1189
|
+
googleSub,
|
|
1190
|
+
email,
|
|
1191
|
+
ownerJid,
|
|
1192
|
+
}).catch(() => 0);
|
|
1193
|
+
|
|
1194
|
+
await createAdminActionAuditEvent({
|
|
1195
|
+
adminSession,
|
|
1196
|
+
action: 'force_logout',
|
|
1197
|
+
targetType: 'google_web_session',
|
|
1198
|
+
targetId: sessionToken || googleSub || email || ownerJid,
|
|
1199
|
+
details: { removed_sessions: Number(removed || 0), google_sub: googleSub || null, email: email || null, owner_jid: ownerJid || null },
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
sendJson(req, res, 200, {
|
|
1203
|
+
data: {
|
|
1204
|
+
removed_sessions: Number(removed || 0),
|
|
1205
|
+
target: {
|
|
1206
|
+
session_token: sessionToken || null,
|
|
1207
|
+
google_sub: googleSub || null,
|
|
1208
|
+
email: email || null,
|
|
1209
|
+
owner_jid: ownerJid || null,
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const handleAdminFeatureFlagsRequest = async (req, res) => {
|
|
1216
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1217
|
+
if (!adminSession) return;
|
|
1218
|
+
|
|
1219
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1220
|
+
const flags = await listAdminFeatureFlagsDetailed({ limit: 400 }).catch(() => []);
|
|
1221
|
+
sendJson(req, res, 200, { data: { flags } });
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (req.method !== 'POST') {
|
|
1226
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
let payload = {};
|
|
1231
|
+
try {
|
|
1232
|
+
payload = await readJsonBody(req);
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Body inválido.' });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
try {
|
|
1239
|
+
const flag = await upsertAdminFeatureFlagRecord({
|
|
1240
|
+
adminSession,
|
|
1241
|
+
flagName: payload?.flag_name,
|
|
1242
|
+
isEnabled: Boolean(payload?.is_enabled),
|
|
1243
|
+
rolloutPercent: payload?.rollout_percent,
|
|
1244
|
+
description: payload?.description,
|
|
1245
|
+
});
|
|
1246
|
+
await createAdminActionAuditEvent({
|
|
1247
|
+
adminSession,
|
|
1248
|
+
action: 'feature_flag_update',
|
|
1249
|
+
targetType: 'feature_flag',
|
|
1250
|
+
targetId: flag.flag_name,
|
|
1251
|
+
details: {
|
|
1252
|
+
is_enabled: flag.is_enabled,
|
|
1253
|
+
rollout_percent: flag.rollout_percent,
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
sendJson(req, res, 200, { data: { flag } });
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Falha ao atualizar feature flag.' });
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
const handleAdminOpsActionRequest = async (req, res) => {
|
|
1263
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1264
|
+
if (!adminSession) return;
|
|
1265
|
+
if (req.method !== 'POST') {
|
|
1266
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let payload = {};
|
|
1271
|
+
try {
|
|
1272
|
+
payload = await readJsonBody(req);
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Body inválido.' });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const action = sanitizeAuditActionText(payload?.action || '', 64);
|
|
1279
|
+
if (!action) {
|
|
1280
|
+
sendJson(req, res, 400, { error: 'Informe a ação operacional.' });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
try {
|
|
1285
|
+
if (action === 'clear_cache') {
|
|
1286
|
+
invalidateStickerCatalogDerivedCaches();
|
|
1287
|
+
await createAdminActionAuditEvent({
|
|
1288
|
+
adminSession,
|
|
1289
|
+
action: 'ops_clear_cache',
|
|
1290
|
+
targetType: 'cache',
|
|
1291
|
+
targetId: 'global',
|
|
1292
|
+
});
|
|
1293
|
+
sendJson(req, res, 200, { data: { action, success: true, message: 'Caches internos invalidados com sucesso.', updated_at: new Date().toISOString() } });
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (action === 'restart_worker') {
|
|
1298
|
+
const [tasksResult, reprocessResult] = await Promise.all([
|
|
1299
|
+
executeQuery(
|
|
1300
|
+
`UPDATE ${TABLES.STICKER_WORKER_TASK_QUEUE}
|
|
1301
|
+
SET status = 'pending',
|
|
1302
|
+
worker_token = NULL,
|
|
1303
|
+
locked_at = NULL,
|
|
1304
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1305
|
+
WHERE status = 'processing'`,
|
|
1306
|
+
).catch(() => ({ affectedRows: 0 })),
|
|
1307
|
+
executeQuery(
|
|
1308
|
+
`UPDATE ${TABLES.STICKER_ASSET_REPROCESS_QUEUE}
|
|
1309
|
+
SET status = 'pending',
|
|
1310
|
+
worker_token = NULL,
|
|
1311
|
+
locked_at = NULL,
|
|
1312
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1313
|
+
WHERE status = 'processing'`,
|
|
1314
|
+
).catch(() => ({ affectedRows: 0 })),
|
|
1315
|
+
]);
|
|
1316
|
+
|
|
1317
|
+
const released = Number(tasksResult?.affectedRows || 0) + Number(reprocessResult?.affectedRows || 0);
|
|
1318
|
+
await createAdminActionAuditEvent({
|
|
1319
|
+
adminSession,
|
|
1320
|
+
action: 'ops_restart_worker',
|
|
1321
|
+
targetType: 'worker',
|
|
1322
|
+
targetId: 'queues',
|
|
1323
|
+
details: {
|
|
1324
|
+
released_tasks: released,
|
|
1325
|
+
task_queue: Number(tasksResult?.affectedRows || 0),
|
|
1326
|
+
reprocess_queue: Number(reprocessResult?.affectedRows || 0),
|
|
1327
|
+
},
|
|
1328
|
+
});
|
|
1329
|
+
sendJson(req, res, 200, {
|
|
1330
|
+
data: {
|
|
1331
|
+
action,
|
|
1332
|
+
success: true,
|
|
1333
|
+
released_processing_items: released,
|
|
1334
|
+
message: released > 0 ? 'Itens em processamento foram recolocados em pending.' : 'Nenhum item travado encontrado nas filas.',
|
|
1335
|
+
updated_at: new Date().toISOString(),
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (action === 'reprocess_jobs') {
|
|
1342
|
+
const payloadJson = JSON.stringify({
|
|
1343
|
+
source: 'admin_panel',
|
|
1344
|
+
requested_by: normalizeEmail(adminSession?.email) || normalizeGoogleSubject(adminSession?.googleSub) || 'admin',
|
|
1345
|
+
requested_at: new Date().toISOString(),
|
|
1346
|
+
});
|
|
1347
|
+
await executeQuery(
|
|
1348
|
+
`INSERT INTO ${TABLES.STICKER_WORKER_TASK_QUEUE}
|
|
1349
|
+
(task_type, payload, priority, scheduled_at, status, max_attempts)
|
|
1350
|
+
VALUES
|
|
1351
|
+
('classification_cycle', ?, 10, UTC_TIMESTAMP(), 'pending', 5),
|
|
1352
|
+
('curation_cycle', ?, 12, UTC_TIMESTAMP(), 'pending', 5)`,
|
|
1353
|
+
[payloadJson, payloadJson],
|
|
1354
|
+
);
|
|
1355
|
+
await createAdminActionAuditEvent({
|
|
1356
|
+
adminSession,
|
|
1357
|
+
action: 'ops_reprocess_jobs',
|
|
1358
|
+
targetType: 'worker',
|
|
1359
|
+
targetId: 'classification_cycle,curation_cycle',
|
|
1360
|
+
});
|
|
1361
|
+
sendJson(req, res, 200, {
|
|
1362
|
+
data: {
|
|
1363
|
+
action,
|
|
1364
|
+
success: true,
|
|
1365
|
+
enqueued_tasks: 2,
|
|
1366
|
+
message: 'Ciclos de classificação e curadoria foram agendados.',
|
|
1367
|
+
updated_at: new Date().toISOString(),
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
sendJson(req, res, 400, { error: 'Ação operacional inválida.' });
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
sendJson(req, res, 500, { error: error?.message || 'Falha ao executar ação operacional.' });
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
const handleAdminSearchRequest = async (req, res, url) => {
|
|
1380
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1381
|
+
if (!adminSession) return;
|
|
1382
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
1383
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const q = sanitizeText(url?.searchParams?.get('q') || '', 120, { allowEmpty: true }) || '';
|
|
1388
|
+
const limit = Math.max(1, Math.min(30, Number(url?.searchParams?.get('limit') || 12)));
|
|
1389
|
+
if (!q) {
|
|
1390
|
+
sendJson(req, res, 200, {
|
|
1391
|
+
data: {
|
|
1392
|
+
q: '',
|
|
1393
|
+
totals: { users: 0, sessions: 0, groups: 0, packs: 0 },
|
|
1394
|
+
results: { users: [], sessions: [], groups: [], packs: [] },
|
|
1395
|
+
},
|
|
1396
|
+
});
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const like = `%${q}%`;
|
|
1401
|
+
|
|
1402
|
+
const [usersRows, sessionsRows, groupsRows, packs] = await Promise.all([
|
|
1403
|
+
executeQuery(
|
|
1404
|
+
`SELECT google_sub, email, name, owner_jid, owner_phone, last_seen_at, last_login_at
|
|
1405
|
+
FROM ${TABLES.STICKER_WEB_GOOGLE_USER}
|
|
1406
|
+
WHERE google_sub LIKE ? OR email LIKE ? OR name LIKE ? OR owner_jid LIKE ? OR owner_phone LIKE ?
|
|
1407
|
+
ORDER BY COALESCE(last_seen_at, last_login_at, created_at) DESC
|
|
1408
|
+
LIMIT ${limit}`,
|
|
1409
|
+
[like, like, like, like, like],
|
|
1410
|
+
).catch(() => []),
|
|
1411
|
+
executeQuery(
|
|
1412
|
+
`SELECT session_token, google_sub, email, name, owner_jid, owner_phone, last_seen_at, expires_at
|
|
1413
|
+
FROM ${TABLES.STICKER_WEB_GOOGLE_SESSION}
|
|
1414
|
+
WHERE revoked_at IS NULL
|
|
1415
|
+
AND expires_at > UTC_TIMESTAMP()
|
|
1416
|
+
AND (session_token LIKE ? OR google_sub LIKE ? OR email LIKE ? OR name LIKE ? OR owner_jid LIKE ? OR owner_phone LIKE ?)
|
|
1417
|
+
ORDER BY COALESCE(last_seen_at, created_at) DESC
|
|
1418
|
+
LIMIT ${limit}`,
|
|
1419
|
+
[like, like, like, like, like, like],
|
|
1420
|
+
).catch(() => []),
|
|
1421
|
+
executeQuery(
|
|
1422
|
+
`SELECT
|
|
1423
|
+
gm.id,
|
|
1424
|
+
COALESCE(NULLIF(gm.subject, ''), ch.name, gm.id) AS subject,
|
|
1425
|
+
gm.owner_jid,
|
|
1426
|
+
gm.updated_at
|
|
1427
|
+
FROM ${TABLES.GROUPS_METADATA} gm
|
|
1428
|
+
LEFT JOIN ${TABLES.CHATS} ch ON ch.id = gm.id
|
|
1429
|
+
WHERE gm.id LIKE ? OR gm.subject LIKE ? OR ch.name LIKE ? OR gm.owner_jid LIKE ?
|
|
1430
|
+
ORDER BY gm.updated_at DESC
|
|
1431
|
+
LIMIT ${limit}`,
|
|
1432
|
+
[like, like, like, like],
|
|
1433
|
+
).catch(() => []),
|
|
1434
|
+
listAdminPacks({
|
|
1435
|
+
searchParams: new URLSearchParams([
|
|
1436
|
+
['q', q],
|
|
1437
|
+
['limit', String(limit)],
|
|
1438
|
+
]),
|
|
1439
|
+
}).catch(() => []),
|
|
1440
|
+
]);
|
|
1441
|
+
|
|
1442
|
+
const users = (Array.isArray(usersRows) ? usersRows : []).map((row) => ({
|
|
1443
|
+
google_sub: normalizeGoogleSubject(row?.google_sub),
|
|
1444
|
+
email: normalizeEmail(row?.email) || null,
|
|
1445
|
+
name: sanitizeText(row?.name || '', 120, { allowEmpty: true }) || null,
|
|
1446
|
+
owner_jid: normalizeJid(row?.owner_jid) || null,
|
|
1447
|
+
owner_phone: toWhatsAppPhoneDigits(row?.owner_phone || row?.owner_jid) || null,
|
|
1448
|
+
last_seen_at: toIsoOrNull(row?.last_seen_at),
|
|
1449
|
+
last_login_at: toIsoOrNull(row?.last_login_at),
|
|
1450
|
+
}));
|
|
1451
|
+
|
|
1452
|
+
const sessions = (Array.isArray(sessionsRows) ? sessionsRows : []).map((row) => ({
|
|
1453
|
+
session_token: String(row?.session_token || '').trim() || null,
|
|
1454
|
+
google_sub: normalizeGoogleSubject(row?.google_sub),
|
|
1455
|
+
email: normalizeEmail(row?.email) || null,
|
|
1456
|
+
name: sanitizeText(row?.name || '', 120, { allowEmpty: true }) || null,
|
|
1457
|
+
owner_jid: normalizeJid(row?.owner_jid) || null,
|
|
1458
|
+
owner_phone: toWhatsAppPhoneDigits(row?.owner_phone || row?.owner_jid) || null,
|
|
1459
|
+
last_seen_at: toIsoOrNull(row?.last_seen_at),
|
|
1460
|
+
expires_at: toIsoOrNull(row?.expires_at),
|
|
1461
|
+
}));
|
|
1462
|
+
|
|
1463
|
+
const groups = (Array.isArray(groupsRows) ? groupsRows : []).map((row) => ({
|
|
1464
|
+
id: String(row?.id || '').trim(),
|
|
1465
|
+
subject: sanitizeText(row?.subject || row?.id || '', 255, { allowEmpty: true }) || String(row?.id || '').trim(),
|
|
1466
|
+
owner_jid: normalizeJid(row?.owner_jid) || null,
|
|
1467
|
+
updated_at: toIsoOrNull(row?.updated_at),
|
|
1468
|
+
}));
|
|
1469
|
+
|
|
1470
|
+
sendJson(req, res, 200, {
|
|
1471
|
+
data: {
|
|
1472
|
+
q,
|
|
1473
|
+
totals: {
|
|
1474
|
+
users: users.length,
|
|
1475
|
+
sessions: sessions.length,
|
|
1476
|
+
groups: groups.length,
|
|
1477
|
+
packs: Array.isArray(packs) ? packs.length : 0,
|
|
1478
|
+
},
|
|
1479
|
+
results: {
|
|
1480
|
+
users,
|
|
1481
|
+
sessions,
|
|
1482
|
+
groups,
|
|
1483
|
+
packs: Array.isArray(packs) ? packs : [],
|
|
1484
|
+
},
|
|
1485
|
+
},
|
|
1486
|
+
});
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const toCsvValue = (value) => {
|
|
1490
|
+
const normalized = value === null || value === undefined ? '' : String(value);
|
|
1491
|
+
if (/[",\n;]/.test(normalized)) {
|
|
1492
|
+
return `"${normalized.replaceAll('"', '""')}"`;
|
|
1493
|
+
}
|
|
1494
|
+
return normalized;
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
const buildCsv = (rows = [], headers = []) => {
|
|
1498
|
+
const safeRows = Array.isArray(rows) ? rows : [];
|
|
1499
|
+
const safeHeaders = Array.isArray(headers) ? headers : [];
|
|
1500
|
+
const lines = [];
|
|
1501
|
+
lines.push(safeHeaders.map((header) => toCsvValue(header)).join(','));
|
|
1502
|
+
for (const row of safeRows) {
|
|
1503
|
+
lines.push(
|
|
1504
|
+
safeHeaders
|
|
1505
|
+
.map((header) => {
|
|
1506
|
+
const value = row && typeof row === 'object' ? row[header] : '';
|
|
1507
|
+
return toCsvValue(value);
|
|
1508
|
+
})
|
|
1509
|
+
.join(','),
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
return `${lines.join('\n')}\n`;
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
const handleAdminExportRequest = async (req, res, url) => {
|
|
1516
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1517
|
+
if (!adminSession) return;
|
|
1518
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
1519
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const format = String(url?.searchParams?.get('format') || 'json')
|
|
1524
|
+
.trim()
|
|
1525
|
+
.toLowerCase();
|
|
1526
|
+
const type = String(url?.searchParams?.get('type') || 'metrics')
|
|
1527
|
+
.trim()
|
|
1528
|
+
.toLowerCase();
|
|
1529
|
+
|
|
1530
|
+
const overview = await buildAdminOverviewPayload({ adminSession });
|
|
1531
|
+
const exportData =
|
|
1532
|
+
type === 'events'
|
|
1533
|
+
? {
|
|
1534
|
+
moderation_queue: overview?.moderation_queue || [],
|
|
1535
|
+
audit_log: overview?.audit_log || [],
|
|
1536
|
+
blocked_accounts: overview?.users_sessions?.blocked_accounts || [],
|
|
1537
|
+
}
|
|
1538
|
+
: {
|
|
1539
|
+
dashboard_quick: overview?.dashboard_quick || null,
|
|
1540
|
+
counters: overview?.counters || null,
|
|
1541
|
+
system_health: overview?.system_health || null,
|
|
1542
|
+
alerts: overview?.alerts || [],
|
|
1543
|
+
feature_flags: overview?.feature_flags || [],
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
await createAdminActionAuditEvent({
|
|
1547
|
+
adminSession,
|
|
1548
|
+
action: 'export_data',
|
|
1549
|
+
targetType: 'admin_export',
|
|
1550
|
+
targetId: `${type}.${format}`,
|
|
1551
|
+
details: { type, format },
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
if (format !== 'csv') {
|
|
1555
|
+
sendJson(req, res, 200, {
|
|
1556
|
+
data: {
|
|
1557
|
+
type,
|
|
1558
|
+
format: 'json',
|
|
1559
|
+
exported_at: new Date().toISOString(),
|
|
1560
|
+
payload: exportData,
|
|
1561
|
+
},
|
|
1562
|
+
});
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
let headers = [];
|
|
1567
|
+
let rows = [];
|
|
1568
|
+
|
|
1569
|
+
if (type === 'events') {
|
|
1570
|
+
headers = ['section', 'id', 'event_type', 'severity', 'title', 'subtitle', 'status', 'created_at'];
|
|
1571
|
+
rows = [
|
|
1572
|
+
...(Array.isArray(exportData?.moderation_queue) ? exportData.moderation_queue : []).map((item) => ({
|
|
1573
|
+
section: 'moderation_queue',
|
|
1574
|
+
id: item?.id || '',
|
|
1575
|
+
event_type: item?.event_type || '',
|
|
1576
|
+
severity: item?.severity || '',
|
|
1577
|
+
title: item?.title || '',
|
|
1578
|
+
subtitle: item?.subtitle || '',
|
|
1579
|
+
status: item?.revoked_at ? 'revoked' : item?.status || '',
|
|
1580
|
+
created_at: item?.created_at || item?.revoked_at || '',
|
|
1581
|
+
})),
|
|
1582
|
+
...(Array.isArray(exportData?.audit_log) ? exportData.audit_log : []).map((item) => ({
|
|
1583
|
+
section: 'audit_log',
|
|
1584
|
+
id: item?.id || '',
|
|
1585
|
+
event_type: item?.action || '',
|
|
1586
|
+
severity: item?.status || '',
|
|
1587
|
+
title: item?.target_type || '',
|
|
1588
|
+
subtitle: item?.target_id || '',
|
|
1589
|
+
status: item?.status || '',
|
|
1590
|
+
created_at: item?.created_at || '',
|
|
1591
|
+
})),
|
|
1592
|
+
...(Array.isArray(exportData?.blocked_accounts) ? exportData.blocked_accounts : []).map((item) => ({
|
|
1593
|
+
section: 'blocked_accounts',
|
|
1594
|
+
id: item?.id || '',
|
|
1595
|
+
event_type: 'ban',
|
|
1596
|
+
severity: item?.revoked_at ? 'low' : 'critical',
|
|
1597
|
+
title: item?.email || item?.owner_jid || item?.google_sub || '',
|
|
1598
|
+
subtitle: item?.reason || '',
|
|
1599
|
+
status: item?.revoked_at ? 'revoked' : 'active',
|
|
1600
|
+
created_at: item?.created_at || '',
|
|
1601
|
+
})),
|
|
1602
|
+
];
|
|
1603
|
+
} else {
|
|
1604
|
+
headers = ['section', 'key', 'value'];
|
|
1605
|
+
const dashboard = exportData?.dashboard_quick || {};
|
|
1606
|
+
const counters = exportData?.counters || {};
|
|
1607
|
+
const health = exportData?.system_health || {};
|
|
1608
|
+
const alerts = Array.isArray(exportData?.alerts) ? exportData.alerts : [];
|
|
1609
|
+
const flags = Array.isArray(exportData?.feature_flags) ? exportData.feature_flags : [];
|
|
1610
|
+
rows = [...Object.entries(dashboard).map(([key, value]) => ({ section: 'dashboard_quick', key, value })), ...Object.entries(counters).map(([key, value]) => ({ section: 'counters', key, value })), ...Object.entries(health).map(([key, value]) => ({ section: 'system_health', key, value })), ...alerts.map((alert, index) => ({ section: 'alerts', key: `${index + 1}:${alert?.code || 'alert'}`, value: `${alert?.severity || ''} ${alert?.title || ''}`.trim() })), ...flags.map((flag) => ({ section: 'feature_flags', key: flag?.flag_name || '', value: `${flag?.is_enabled ? 'on' : 'off'} (${flag?.rollout_percent || 0}%)` }))];
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const csv = buildCsv(rows, headers);
|
|
1614
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
1615
|
+
res.statusCode = 200;
|
|
1616
|
+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
1617
|
+
res.setHeader('Content-Disposition', `attachment; filename="admin-${type}-${stamp}.csv"`);
|
|
1618
|
+
res.end(csv);
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
const handleAdminModeratorsRequest = async (req, res) => {
|
|
1622
|
+
const adminSession = requireOwnerAdminPanelSession(req, res);
|
|
1623
|
+
if (!adminSession) return;
|
|
1624
|
+
|
|
1625
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1626
|
+
const moderators = await listAdminModerators({ activeOnly: false, limit: 500 });
|
|
1627
|
+
sendJson(req, res, 200, { data: { moderators } });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (req.method !== 'POST') {
|
|
1632
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
let payload = {};
|
|
1637
|
+
try {
|
|
1638
|
+
payload = await readJsonBody(req);
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Body inválido.' });
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
try {
|
|
1645
|
+
const result = await upsertAdminModeratorRecord({
|
|
1646
|
+
googleSub: payload?.google_sub,
|
|
1647
|
+
email: payload?.email,
|
|
1648
|
+
ownerJid: payload?.owner_jid,
|
|
1649
|
+
password: payload?.password,
|
|
1650
|
+
adminSession,
|
|
1651
|
+
});
|
|
1652
|
+
await createAdminActionAuditEvent({
|
|
1653
|
+
adminSession,
|
|
1654
|
+
action: result.created ? 'moderator_create' : 'moderator_update',
|
|
1655
|
+
targetType: 'moderator',
|
|
1656
|
+
targetId: result?.moderator?.google_sub || payload?.google_sub || '',
|
|
1657
|
+
details: { created: Boolean(result.created) },
|
|
1658
|
+
});
|
|
1659
|
+
sendJson(req, res, result.created ? 201 : 200, {
|
|
1660
|
+
data: {
|
|
1661
|
+
created: result.created,
|
|
1662
|
+
moderator: result.moderator,
|
|
1663
|
+
},
|
|
1664
|
+
});
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Falha ao salvar moderador.' });
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
const handleAdminModeratorDeleteRequest = async (req, res, googleSub) => {
|
|
1671
|
+
const adminSession = requireOwnerAdminPanelSession(req, res);
|
|
1672
|
+
if (!adminSession) return;
|
|
1673
|
+
if (req.method !== 'DELETE') {
|
|
1674
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
try {
|
|
1678
|
+
const moderator = await revokeAdminModeratorRecord(googleSub, adminSession);
|
|
1679
|
+
await createAdminActionAuditEvent({
|
|
1680
|
+
adminSession,
|
|
1681
|
+
action: 'moderator_revoke',
|
|
1682
|
+
targetType: 'moderator',
|
|
1683
|
+
targetId: moderator?.google_sub || googleSub,
|
|
1684
|
+
});
|
|
1685
|
+
sendJson(req, res, 200, { data: { revoked: true, moderator } });
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Falha ao remover moderador.' });
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
const handleAdminPacksRequest = async (req, res, url) => {
|
|
1692
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1693
|
+
if (!adminSession) return;
|
|
1694
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
1695
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const packs = await listAdminPacks(url);
|
|
1699
|
+
sendJson(req, res, 200, { data: packs });
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
const handleAdminPackDetailsRequest = async (req, res, packKey) => {
|
|
1703
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1704
|
+
if (!adminSession) return;
|
|
1705
|
+
if (!['GET', 'HEAD'].includes(req.method || '')) {
|
|
1706
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const context = await findAdminPackContextByKey(packKey);
|
|
1710
|
+
if (!context?.fullPack) {
|
|
1711
|
+
sendJson(req, res, 404, { error: 'Pack nao encontrado.' });
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const data = await buildManagedPackResponseData(context.fullPack);
|
|
1715
|
+
sendJson(req, res, 200, { data });
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1718
|
+
const handleAdminPackDeleteRequest = async (req, res, packKey) => {
|
|
1719
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1720
|
+
if (!adminSession) return;
|
|
1721
|
+
if (req.method !== 'DELETE') {
|
|
1722
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const context = await findAdminPackContextByKey(packKey);
|
|
1726
|
+
const normalizedPackKey = sanitizeText(packKey, 160, { allowEmpty: false }) || String(packKey || '');
|
|
1727
|
+
if (!context?.fullPack) {
|
|
1728
|
+
sendManagedMutationStatus(req, res, 'already_deleted', {
|
|
1729
|
+
deleted: false,
|
|
1730
|
+
pack_key: normalizedPackKey,
|
|
1731
|
+
admin: true,
|
|
1732
|
+
});
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const result = await deleteManagedPackWithCleanup({
|
|
1736
|
+
ownerJid: context.ownerJid,
|
|
1737
|
+
identifier: context.packKey,
|
|
1738
|
+
fallbackPack: context.fullPack,
|
|
1739
|
+
});
|
|
1740
|
+
await createAdminActionAuditEvent({
|
|
1741
|
+
adminSession,
|
|
1742
|
+
action: 'pack_delete',
|
|
1743
|
+
targetType: 'pack',
|
|
1744
|
+
targetId: result?.deletedPack?.pack_key || context.packKey || packKey,
|
|
1745
|
+
details: {
|
|
1746
|
+
removed_sticker_count: Number(result?.removedCount || 0),
|
|
1747
|
+
missing: Boolean(result?.missing),
|
|
1748
|
+
},
|
|
1749
|
+
});
|
|
1750
|
+
sendManagedMutationStatus(req, res, 'deleted', {
|
|
1751
|
+
admin: true,
|
|
1752
|
+
deleted: !result?.missing,
|
|
1753
|
+
pack_key: result?.deletedPack?.pack_key || context.packKey,
|
|
1754
|
+
id: result?.deletedPack?.id || context.fullPack?.id || null,
|
|
1755
|
+
deleted_at: toIsoOrNull(result?.deletedPack?.deleted_at || new Date()),
|
|
1756
|
+
removed_sticker_count: Number(result?.removedCount || 0),
|
|
1757
|
+
});
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
const handleAdminPackStickerDeleteRequest = async (req, res, packKey, stickerId) => {
|
|
1761
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1762
|
+
if (!adminSession) return;
|
|
1763
|
+
if (req.method !== 'DELETE') {
|
|
1764
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const context = await findAdminPackContextByKey(packKey);
|
|
1768
|
+
const normalizedStickerId = sanitizeText(stickerId, 36, { allowEmpty: false });
|
|
1769
|
+
if (!context?.fullPack) {
|
|
1770
|
+
sendManagedMutationStatus(req, res, 'already_deleted', {
|
|
1771
|
+
admin: true,
|
|
1772
|
+
pack_key: sanitizeText(packKey, 160, { allowEmpty: true }) || String(packKey || ''),
|
|
1773
|
+
sticker_id: normalizedStickerId || null,
|
|
1774
|
+
});
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
try {
|
|
1778
|
+
const result = await stickerPackService.removeStickerFromPack({
|
|
1779
|
+
ownerJid: context.ownerJid,
|
|
1780
|
+
identifier: context.packKey,
|
|
1781
|
+
selector: normalizedStickerId,
|
|
1782
|
+
});
|
|
1783
|
+
invalidateStickerCatalogDerivedCaches();
|
|
1784
|
+
if (normalizedStickerId) {
|
|
1785
|
+
await cleanupOrphanStickerAssets([normalizedStickerId], { reason: 'admin_remove_sticker' });
|
|
1786
|
+
}
|
|
1787
|
+
await createAdminActionAuditEvent({
|
|
1788
|
+
adminSession,
|
|
1789
|
+
action: 'pack_sticker_delete',
|
|
1790
|
+
targetType: 'sticker',
|
|
1791
|
+
targetId: normalizedStickerId || stickerId,
|
|
1792
|
+
details: {
|
|
1793
|
+
pack_key: context.packKey,
|
|
1794
|
+
},
|
|
1795
|
+
});
|
|
1796
|
+
await sendManagedPackMutationStatus(req, res, 'updated', result?.pack || context.fullPack, {
|
|
1797
|
+
admin: true,
|
|
1798
|
+
pack_key: context.packKey,
|
|
1799
|
+
removed_sticker_id: normalizedStickerId || null,
|
|
1800
|
+
});
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
const mapped = mapStickerPackWebManageError(error);
|
|
1803
|
+
sendJson(req, res, mapped.statusCode, { error: mapped.message, code: mapped.code });
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
const handleAdminGlobalStickerDeleteRequest = async (req, res, stickerId) => {
|
|
1808
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1809
|
+
if (!adminSession) return;
|
|
1810
|
+
if (req.method !== 'DELETE') {
|
|
1811
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const normalizedStickerId = sanitizeText(stickerId, 36, { allowEmpty: false });
|
|
1815
|
+
if (!normalizedStickerId) {
|
|
1816
|
+
sendJson(req, res, 400, { error: 'sticker_id invalido.' });
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const refs = await executeQuery(
|
|
1821
|
+
`SELECT p.pack_key, p.owner_jid
|
|
1822
|
+
FROM ${TABLES.STICKER_PACK_ITEM} i
|
|
1823
|
+
INNER JOIN ${TABLES.STICKER_PACK} p ON p.id = i.pack_id
|
|
1824
|
+
WHERE i.sticker_id = ?
|
|
1825
|
+
AND p.deleted_at IS NULL`,
|
|
1826
|
+
[normalizedStickerId],
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
let removedFromPacks = 0;
|
|
1830
|
+
let removeErrors = 0;
|
|
1831
|
+
for (const ref of Array.isArray(refs) ? refs : []) {
|
|
1832
|
+
try {
|
|
1833
|
+
const ownerJid = normalizeJid(ref.owner_jid) || '';
|
|
1834
|
+
const packKey = sanitizeText(ref.pack_key, 160, { allowEmpty: false });
|
|
1835
|
+
if (!ownerJid || !packKey) continue;
|
|
1836
|
+
await stickerPackService.removeStickerFromPack({
|
|
1837
|
+
ownerJid,
|
|
1838
|
+
identifier: packKey,
|
|
1839
|
+
selector: normalizedStickerId,
|
|
1840
|
+
});
|
|
1841
|
+
removedFromPacks += 1;
|
|
1842
|
+
} catch {
|
|
1843
|
+
removeErrors += 1;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const cleanup = await cleanupOrphanStickerAssets([normalizedStickerId], { reason: 'admin_delete_sticker_global' });
|
|
1848
|
+
invalidateStickerCatalogDerivedCaches();
|
|
1849
|
+
await createAdminActionAuditEvent({
|
|
1850
|
+
adminSession,
|
|
1851
|
+
action: 'global_sticker_delete',
|
|
1852
|
+
targetType: 'sticker',
|
|
1853
|
+
targetId: normalizedStickerId,
|
|
1854
|
+
details: {
|
|
1855
|
+
removed_from_packs: removedFromPacks,
|
|
1856
|
+
remove_errors: removeErrors,
|
|
1857
|
+
cleanup_deleted: Number(cleanup?.deleted || 0),
|
|
1858
|
+
},
|
|
1859
|
+
});
|
|
1860
|
+
sendJson(req, res, 200, {
|
|
1861
|
+
data: {
|
|
1862
|
+
success: true,
|
|
1863
|
+
sticker_id: normalizedStickerId,
|
|
1864
|
+
removed_from_packs: removedFromPacks,
|
|
1865
|
+
remove_errors: removeErrors,
|
|
1866
|
+
cleanup,
|
|
1867
|
+
deleted: Number(cleanup?.deleted || 0) > 0,
|
|
1868
|
+
},
|
|
1869
|
+
});
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
const handleAdminBansRequest = async (req, res) => {
|
|
1873
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1874
|
+
if (!adminSession) return;
|
|
1875
|
+
|
|
1876
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
1877
|
+
const bans = await listAdminBans({ activeOnly: false, limit: 200 });
|
|
1878
|
+
sendJson(req, res, 200, { data: bans });
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (req.method !== 'POST') {
|
|
1883
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
let payload = {};
|
|
1888
|
+
try {
|
|
1889
|
+
payload = await readJsonBody(req);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Body inválido.' });
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
try {
|
|
1896
|
+
const result = await createAdminBanRecord({
|
|
1897
|
+
googleSub: payload?.google_sub,
|
|
1898
|
+
email: payload?.email,
|
|
1899
|
+
ownerJid: payload?.owner_jid,
|
|
1900
|
+
reason: payload?.reason,
|
|
1901
|
+
adminSession,
|
|
1902
|
+
});
|
|
1903
|
+
await createAdminActionAuditEvent({
|
|
1904
|
+
adminSession,
|
|
1905
|
+
action: result.created ? 'ban_create' : 'ban_existing',
|
|
1906
|
+
targetType: 'ban',
|
|
1907
|
+
targetId: result?.ban?.id || '',
|
|
1908
|
+
details: {
|
|
1909
|
+
google_sub: result?.ban?.google_sub || payload?.google_sub || null,
|
|
1910
|
+
email: result?.ban?.email || payload?.email || null,
|
|
1911
|
+
owner_jid: result?.ban?.owner_jid || payload?.owner_jid || null,
|
|
1912
|
+
},
|
|
1913
|
+
});
|
|
1914
|
+
sendJson(req, res, result.created ? 201 : 200, {
|
|
1915
|
+
data: {
|
|
1916
|
+
created: result.created,
|
|
1917
|
+
ban: result.ban,
|
|
1918
|
+
},
|
|
1919
|
+
});
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Falha ao banir usuário.' });
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
const handleAdminBanRevokeRequest = async (req, res, banId) => {
|
|
1926
|
+
const adminSession = requireAdminPanelSession(req, res);
|
|
1927
|
+
if (!adminSession) return;
|
|
1928
|
+
if (req.method !== 'DELETE') {
|
|
1929
|
+
sendJson(req, res, 405, { error: 'Metodo nao permitido.' });
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
try {
|
|
1933
|
+
const ban = await revokeAdminBanRecord(banId);
|
|
1934
|
+
await createAdminActionAuditEvent({
|
|
1935
|
+
adminSession,
|
|
1936
|
+
action: 'ban_revoke',
|
|
1937
|
+
targetType: 'ban',
|
|
1938
|
+
targetId: ban?.id || banId,
|
|
1939
|
+
});
|
|
1940
|
+
sendJson(req, res, 200, { data: { revoked: true, ban } });
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
sendJson(req, res, Number(error?.statusCode || 400), { error: error?.message || 'Falha ao revogar ban.' });
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
return {
|
|
1947
|
+
handleAdminPanelSessionRequest,
|
|
1948
|
+
handleAdminOverviewRequest,
|
|
1949
|
+
handleAdminUsersRequest,
|
|
1950
|
+
handleAdminForceLogoutRequest,
|
|
1951
|
+
handleAdminFeatureFlagsRequest,
|
|
1952
|
+
handleAdminOpsActionRequest,
|
|
1953
|
+
handleAdminSearchRequest,
|
|
1954
|
+
handleAdminExportRequest,
|
|
1955
|
+
handleAdminModeratorsRequest,
|
|
1956
|
+
handleAdminModeratorDeleteRequest,
|
|
1957
|
+
handleAdminPacksRequest,
|
|
1958
|
+
handleAdminPackDetailsRequest,
|
|
1959
|
+
handleAdminPackDeleteRequest,
|
|
1960
|
+
handleAdminPackStickerDeleteRequest,
|
|
1961
|
+
handleAdminGlobalStickerDeleteRequest,
|
|
1962
|
+
handleAdminBansRequest,
|
|
1963
|
+
handleAdminBanRevokeRequest,
|
|
1964
|
+
};
|
|
1965
|
+
};
|