@kaikybrofc/omnizap-system 2.2.4 → 2.2.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 +5 -0
- package/.prettierrc +16 -0
- package/README.md +13 -13
- package/app/modules/stickerPackModule/autoPackCollectorService.js +63 -8
- package/app/modules/stickerPackModule/catalogHandlers/catalogAdminHttp.js +68 -0
- package/app/modules/stickerPackModule/catalogHandlers/catalogAuthHttp.js +34 -0
- package/app/modules/stickerPackModule/catalogHandlers/catalogPublicHttp.js +179 -0
- package/app/modules/stickerPackModule/catalogHandlers/catalogUploadHttp.js +92 -0
- package/app/modules/stickerPackModule/catalogRouter.js +79 -0
- package/app/modules/stickerPackModule/domainEventOutboxRepository.js +243 -0
- package/app/modules/stickerPackModule/domainEvents.js +61 -0
- package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +21 -0
- package/app/modules/stickerPackModule/stickerAssetRepository.js +19 -0
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +55 -15
- package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +238 -0
- package/app/modules/stickerPackModule/stickerDomainEventBus.js +71 -0
- package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +198 -0
- package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
- package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +570 -536
- package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +44 -0
- package/app/modules/stickerPackModule/stickerPackItemRepository.js +18 -0
- package/app/modules/stickerPackModule/stickerPackRepository.js +51 -0
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +191 -0
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +301 -0
- package/app/modules/stickerPackModule/stickerStorageService.js +111 -10
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +21 -0
- package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +59 -7
- package/app/observability/metrics.js +169 -0
- package/app/services/featureFlagService.js +137 -0
- package/database/index.js +5 -0
- package/database/migrations/20260228_0022_sticker_scale_indexes.sql +16 -0
- package/database/migrations/20260228_0023_sticker_pack_score_snapshot.sql +25 -0
- package/database/migrations/20260228_0024_domain_event_outbox.sql +42 -0
- package/database/migrations/20260228_0025_sticker_worker_task_idempotency_dlq.sql +23 -0
- package/database/migrations/20260228_0026_feature_flags.sql +21 -0
- package/ecosystem.prod.config.cjs +70 -9
- package/index.js +26 -0
- package/kaikybrofc-omnizap-system-2.2.6.tgz +0 -0
- package/observability/sticker-catalog-slo.md +83 -0
- package/observability/sticker-scale-hardening-rollout.md +128 -0
- package/package.json +7 -35
- package/public/assets/images/brand-icon-192.png +0 -0
- package/public/assets/images/brand-logo-128.webp +0 -0
- package/public/assets/images/hero-banner-1280.avif +0 -0
- package/public/assets/images/hero-banner-1280.jpg +0 -0
- package/public/assets/images/hero-banner-1280.webp +0 -0
- package/public/assets/images/hero-banner-720.avif +0 -0
- package/public/assets/images/hero-banner-720.webp +0 -0
- package/public/index.html +120 -18
- package/public/js/apps/homeApp.js +469 -353
- package/public/robots.txt +9 -0
- package/public/sitemap.xml +28 -0
- package/scripts/sticker-catalog-loadtest.mjs +208 -0
- package/scripts/sticker-worker-task.mjs +122 -0
- package/observability/mysql-exporter.cnf +0 -5
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import logger from '../../utils/logger/loggerModule.js';
|
|
2
|
+
import { isFeatureEnabled } from '../../services/featureFlagService.js';
|
|
3
|
+
import { enqueueDomainEvent } from './domainEventOutboxRepository.js';
|
|
4
|
+
|
|
5
|
+
const resolveDefaultIdempotencyKey = ({
|
|
6
|
+
eventType,
|
|
7
|
+
aggregateType,
|
|
8
|
+
aggregateId,
|
|
9
|
+
payload = null,
|
|
10
|
+
}) => {
|
|
11
|
+
const payloadKey = payload && typeof payload === 'object' ? JSON.stringify(payload).slice(0, 80) : '';
|
|
12
|
+
return `${eventType}:${aggregateType}:${aggregateId}:${payloadKey}`.slice(0, 180);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const publishStickerDomainEvent = async (
|
|
16
|
+
eventPayload,
|
|
17
|
+
{ connection = null, force = false } = {},
|
|
18
|
+
) => {
|
|
19
|
+
const eventType = String(eventPayload?.eventType || '').trim();
|
|
20
|
+
const aggregateType = String(eventPayload?.aggregateType || '').trim();
|
|
21
|
+
const aggregateId = String(eventPayload?.aggregateId || '').trim();
|
|
22
|
+
|
|
23
|
+
if (!eventType || !aggregateType || !aggregateId) return false;
|
|
24
|
+
|
|
25
|
+
const enabled = force ? true : await isFeatureEnabled('enable_domain_event_outbox', {
|
|
26
|
+
fallback: true,
|
|
27
|
+
subjectKey: `${aggregateType}:${aggregateId}`,
|
|
28
|
+
});
|
|
29
|
+
if (!enabled) return false;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const idempotencyKey =
|
|
33
|
+
String(eventPayload?.idempotencyKey || '').trim()
|
|
34
|
+
|| resolveDefaultIdempotencyKey({
|
|
35
|
+
eventType,
|
|
36
|
+
aggregateType,
|
|
37
|
+
aggregateId,
|
|
38
|
+
payload: eventPayload?.payload || null,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return await enqueueDomainEvent(
|
|
42
|
+
{
|
|
43
|
+
eventType,
|
|
44
|
+
aggregateType,
|
|
45
|
+
aggregateId,
|
|
46
|
+
payload: eventPayload?.payload || null,
|
|
47
|
+
priority: eventPayload?.priority ?? 50,
|
|
48
|
+
availableAt: eventPayload?.availableAt || null,
|
|
49
|
+
maxAttempts: eventPayload?.maxAttempts ?? 10,
|
|
50
|
+
idempotencyKey,
|
|
51
|
+
},
|
|
52
|
+
connection,
|
|
53
|
+
);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') {
|
|
56
|
+
logger.warn('Outbox indisponível; evento de domínio descartado.', {
|
|
57
|
+
action: 'sticker_domain_event_outbox_unavailable',
|
|
58
|
+
event_type: eventType,
|
|
59
|
+
});
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
logger.warn('Falha ao publicar evento de domínio.', {
|
|
63
|
+
action: 'sticker_domain_event_publish_failed',
|
|
64
|
+
event_type: eventType,
|
|
65
|
+
aggregate_type: aggregateType,
|
|
66
|
+
aggregate_id: aggregateId,
|
|
67
|
+
error: error?.message,
|
|
68
|
+
});
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import logger from '../../utils/logger/loggerModule.js';
|
|
2
|
+
import { setQueueDepth } from '../../observability/metrics.js';
|
|
3
|
+
import { isFeatureEnabled } from '../../services/featureFlagService.js';
|
|
4
|
+
import {
|
|
5
|
+
claimDomainEvent,
|
|
6
|
+
completeDomainEvent,
|
|
7
|
+
countDomainEventsByStatus,
|
|
8
|
+
failDomainEvent,
|
|
9
|
+
} from './domainEventOutboxRepository.js';
|
|
10
|
+
import { STICKER_DOMAIN_EVENTS } from './domainEvents.js';
|
|
11
|
+
import { enqueueWorkerTask } from './stickerWorkerTaskQueueRepository.js';
|
|
12
|
+
import { enqueuePackScoreSnapshotRefresh } from './stickerPackScoreSnapshotRuntime.js';
|
|
13
|
+
import { listPackIdsByStickerId } from './stickerPackItemRepository.js';
|
|
14
|
+
|
|
15
|
+
const parseEnvBool = (value, fallback) => {
|
|
16
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
17
|
+
const normalized = String(value).trim().toLowerCase();
|
|
18
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
|
|
19
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
|
|
20
|
+
return fallback;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const CONSUMER_ENABLED = parseEnvBool(process.env.STICKER_DOMAIN_EVENT_CONSUMER_ENABLED, true);
|
|
24
|
+
const STARTUP_DELAY_MS = Math.max(
|
|
25
|
+
1_000,
|
|
26
|
+
Number(process.env.STICKER_DOMAIN_EVENT_CONSUMER_STARTUP_DELAY_MS) || 8_000,
|
|
27
|
+
);
|
|
28
|
+
const POLLER_INTERVAL_MS = Math.max(
|
|
29
|
+
1_000,
|
|
30
|
+
Number(process.env.STICKER_DOMAIN_EVENT_CONSUMER_POLLER_INTERVAL_MS) || 2_000,
|
|
31
|
+
);
|
|
32
|
+
const RETRY_DELAY_SECONDS = Math.max(
|
|
33
|
+
5,
|
|
34
|
+
Math.min(3600, Number(process.env.STICKER_DOMAIN_EVENT_CONSUMER_RETRY_DELAY_SECONDS) || 45),
|
|
35
|
+
);
|
|
36
|
+
const CONSUMER_COHORT_KEY =
|
|
37
|
+
String(process.env.STICKER_DOMAIN_EVENT_CONSUMER_COHORT_KEY || process.env.HOSTNAME || process.pid).trim()
|
|
38
|
+
|| 'consumer';
|
|
39
|
+
|
|
40
|
+
let startupHandle = null;
|
|
41
|
+
let pollerHandle = null;
|
|
42
|
+
let running = false;
|
|
43
|
+
|
|
44
|
+
const refreshOutboxDepthMetrics = async () => {
|
|
45
|
+
const [pending, processing, failed] = await Promise.all([
|
|
46
|
+
countDomainEventsByStatus('pending'),
|
|
47
|
+
countDomainEventsByStatus('processing'),
|
|
48
|
+
countDomainEventsByStatus('failed'),
|
|
49
|
+
]);
|
|
50
|
+
setQueueDepth('domain_event_outbox_pending', pending);
|
|
51
|
+
setQueueDepth('domain_event_outbox_processing', processing);
|
|
52
|
+
setQueueDepth('domain_event_outbox_failed', failed);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const canRunConsumer = async () =>
|
|
56
|
+
isFeatureEnabled('enable_domain_event_outbox', {
|
|
57
|
+
fallback: true,
|
|
58
|
+
subjectKey: `domain_event_consumer:${CONSUMER_COHORT_KEY}`,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const enqueueTaskSafely = async ({ taskType, payload, priority, idempotencyKey }) => {
|
|
62
|
+
await enqueueWorkerTask({
|
|
63
|
+
taskType,
|
|
64
|
+
payload,
|
|
65
|
+
priority,
|
|
66
|
+
idempotencyKey,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleDomainEvent = async (event) => {
|
|
71
|
+
const eventType = String(event?.event_type || '').trim().toUpperCase();
|
|
72
|
+
const aggregateId = String(event?.aggregate_id || '').trim();
|
|
73
|
+
const payload = event?.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
74
|
+
|
|
75
|
+
if (eventType === STICKER_DOMAIN_EVENTS.STICKER_ASSET_CREATED) {
|
|
76
|
+
await enqueueTaskSafely({
|
|
77
|
+
taskType: 'classification_cycle',
|
|
78
|
+
payload: { reason: 'domain_event', event_type: eventType, aggregate_id: aggregateId },
|
|
79
|
+
priority: 80,
|
|
80
|
+
idempotencyKey: `evt:${event.id}:classification_cycle`,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (eventType === STICKER_DOMAIN_EVENTS.STICKER_CLASSIFIED) {
|
|
86
|
+
const assetId = String(payload?.asset_id || aggregateId || '').trim();
|
|
87
|
+
const relatedPackIds = assetId ? await listPackIdsByStickerId(assetId).catch(() => []) : [];
|
|
88
|
+
if (relatedPackIds.length) {
|
|
89
|
+
enqueuePackScoreSnapshotRefresh(relatedPackIds);
|
|
90
|
+
}
|
|
91
|
+
await enqueueTaskSafely({
|
|
92
|
+
taskType: 'curation_cycle',
|
|
93
|
+
payload: {
|
|
94
|
+
reason: 'domain_event',
|
|
95
|
+
event_type: eventType,
|
|
96
|
+
aggregate_id: aggregateId,
|
|
97
|
+
related_pack_ids: relatedPackIds,
|
|
98
|
+
},
|
|
99
|
+
priority: 65,
|
|
100
|
+
idempotencyKey: `evt:${event.id}:curation_cycle`,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (eventType === STICKER_DOMAIN_EVENTS.PACK_UPDATED) {
|
|
106
|
+
const packId = String(payload?.pack_id || aggregateId || '').trim();
|
|
107
|
+
if (packId) {
|
|
108
|
+
enqueuePackScoreSnapshotRefresh([packId]);
|
|
109
|
+
}
|
|
110
|
+
await enqueueTaskSafely({
|
|
111
|
+
taskType: 'rebuild_cycle',
|
|
112
|
+
payload: { reason: 'domain_event', event_type: eventType, aggregate_id: aggregateId, pack_id: packId || null },
|
|
113
|
+
priority: 60,
|
|
114
|
+
idempotencyKey: `evt:${event.id}:rebuild_cycle`,
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (eventType === STICKER_DOMAIN_EVENTS.ENGAGEMENT_RECORDED) {
|
|
120
|
+
const packId = String(payload?.pack_id || aggregateId || '').trim();
|
|
121
|
+
if (packId) {
|
|
122
|
+
enqueuePackScoreSnapshotRefresh([packId]);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const pollOnce = async () => {
|
|
129
|
+
if (running || !CONSUMER_ENABLED) return;
|
|
130
|
+
running = true;
|
|
131
|
+
try {
|
|
132
|
+
if (!(await canRunConsumer())) return;
|
|
133
|
+
|
|
134
|
+
const event = await claimDomainEvent();
|
|
135
|
+
if (!event) {
|
|
136
|
+
await refreshOutboxDepthMetrics();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await handleDomainEvent(event);
|
|
142
|
+
await completeDomainEvent(event.id);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await failDomainEvent(
|
|
145
|
+
event.id,
|
|
146
|
+
{
|
|
147
|
+
error: error?.message || 'domain_event_consumer_failed',
|
|
148
|
+
retryDelaySeconds: RETRY_DELAY_SECONDS,
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
logger.warn('Evento de domínio falhou no consumidor interno.', {
|
|
152
|
+
action: 'sticker_domain_event_consumer_event_failed',
|
|
153
|
+
event_id: event.id,
|
|
154
|
+
event_type: event.event_type,
|
|
155
|
+
aggregate_type: event.aggregate_type,
|
|
156
|
+
aggregate_id: event.aggregate_id,
|
|
157
|
+
error: error?.message,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await refreshOutboxDepthMetrics();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error?.code !== 'ER_NO_SUCH_TABLE') {
|
|
164
|
+
logger.error('Falha no poller do consumidor de eventos de domínio.', {
|
|
165
|
+
action: 'sticker_domain_event_consumer_poll_failed',
|
|
166
|
+
error: error?.message,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
running = false;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const startStickerDomainEventConsumer = () => {
|
|
175
|
+
if (!CONSUMER_ENABLED) return;
|
|
176
|
+
if (startupHandle || pollerHandle) return;
|
|
177
|
+
|
|
178
|
+
startupHandle = setTimeout(() => {
|
|
179
|
+
startupHandle = null;
|
|
180
|
+
void pollOnce();
|
|
181
|
+
pollerHandle = setInterval(() => {
|
|
182
|
+
void pollOnce();
|
|
183
|
+
}, POLLER_INTERVAL_MS);
|
|
184
|
+
if (typeof pollerHandle?.unref === 'function') pollerHandle.unref();
|
|
185
|
+
}, STARTUP_DELAY_MS);
|
|
186
|
+
if (typeof startupHandle?.unref === 'function') startupHandle.unref();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const stopStickerDomainEventConsumer = () => {
|
|
190
|
+
if (startupHandle) {
|
|
191
|
+
clearTimeout(startupHandle);
|
|
192
|
+
startupHandle = null;
|
|
193
|
+
}
|
|
194
|
+
if (pollerHandle) {
|
|
195
|
+
clearInterval(pollerHandle);
|
|
196
|
+
pollerHandle = null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import logger from '../../utils/logger/loggerModule.js';
|
|
4
|
+
import { normalizeOwnerJid } from './stickerPackUtils.js';
|
|
5
|
+
|
|
6
|
+
const parseEnvBool = (value, fallback) => {
|
|
7
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
8
|
+
const normalized = String(value).trim().toLowerCase();
|
|
9
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
|
|
10
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
|
|
11
|
+
return fallback;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const OBJECT_STORAGE_ENABLED = parseEnvBool(process.env.STICKER_OBJECT_STORAGE_ENABLED, false);
|
|
15
|
+
const OBJECT_STORAGE_UPLOAD_ON_WRITE = parseEnvBool(process.env.STICKER_OBJECT_STORAGE_UPLOAD_ON_WRITE, true);
|
|
16
|
+
const OBJECT_STORAGE_SIGNED_URL_ENABLED = parseEnvBool(process.env.STICKER_OBJECT_STORAGE_SIGNED_URL_ENABLED, true);
|
|
17
|
+
const OBJECT_STORAGE_PROVIDER = String(process.env.STICKER_OBJECT_STORAGE_PROVIDER || 's3').trim().toLowerCase();
|
|
18
|
+
const OBJECT_STORAGE_BUCKET = String(process.env.STICKER_OBJECT_STORAGE_BUCKET || '').trim();
|
|
19
|
+
const OBJECT_STORAGE_REGION = String(process.env.STICKER_OBJECT_STORAGE_REGION || 'us-east-1').trim() || 'us-east-1';
|
|
20
|
+
const OBJECT_STORAGE_ENDPOINT = String(process.env.STICKER_OBJECT_STORAGE_ENDPOINT || '').trim();
|
|
21
|
+
const OBJECT_STORAGE_ACCESS_KEY_ID = String(process.env.STICKER_OBJECT_STORAGE_ACCESS_KEY_ID || '').trim();
|
|
22
|
+
const OBJECT_STORAGE_SECRET_ACCESS_KEY = String(process.env.STICKER_OBJECT_STORAGE_SECRET_ACCESS_KEY || '').trim();
|
|
23
|
+
const OBJECT_STORAGE_FORCE_PATH_STYLE = parseEnvBool(process.env.STICKER_OBJECT_STORAGE_FORCE_PATH_STYLE, true);
|
|
24
|
+
const OBJECT_STORAGE_CDN_BASE_URL = String(process.env.STICKER_OBJECT_STORAGE_CDN_BASE_URL || '').trim().replace(/\/+$/, '');
|
|
25
|
+
const OBJECT_STORAGE_KEY_PREFIX = String(process.env.STICKER_OBJECT_STORAGE_KEY_PREFIX || 'stickers').trim().replace(/^\/+|\/+$/g, '') || 'stickers';
|
|
26
|
+
|
|
27
|
+
let sdkLoadState = {
|
|
28
|
+
loaded: false,
|
|
29
|
+
warned: false,
|
|
30
|
+
S3Client: null,
|
|
31
|
+
PutObjectCommand: null,
|
|
32
|
+
GetObjectCommand: null,
|
|
33
|
+
getSignedUrl: null,
|
|
34
|
+
};
|
|
35
|
+
let s3Client = null;
|
|
36
|
+
|
|
37
|
+
const safeOwnerToken = (ownerJid) => {
|
|
38
|
+
const normalized = normalizeOwnerJid(ownerJid);
|
|
39
|
+
const token = String(normalized || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100);
|
|
40
|
+
return token || 'unknown';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const encodePathSegments = (value) =>
|
|
44
|
+
String(value || '')
|
|
45
|
+
.split('/')
|
|
46
|
+
.map((segment) => encodeURIComponent(segment))
|
|
47
|
+
.join('/');
|
|
48
|
+
|
|
49
|
+
const parseS3StoragePath = (storagePath) => {
|
|
50
|
+
const raw = String(storagePath || '').trim();
|
|
51
|
+
if (!raw.startsWith('s3://')) return null;
|
|
52
|
+
const withoutScheme = raw.slice('s3://'.length);
|
|
53
|
+
const slashIndex = withoutScheme.indexOf('/');
|
|
54
|
+
if (slashIndex < 0) return null;
|
|
55
|
+
const bucket = withoutScheme.slice(0, slashIndex).trim();
|
|
56
|
+
const key = withoutScheme.slice(slashIndex + 1).trim();
|
|
57
|
+
if (!bucket || !key) return null;
|
|
58
|
+
return { bucket, key };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const resolveStickerObjectKey = (asset) => {
|
|
62
|
+
const fromStoragePath = parseS3StoragePath(asset?.storage_path);
|
|
63
|
+
if (fromStoragePath?.key) return fromStoragePath.key;
|
|
64
|
+
const ownerToken = safeOwnerToken(asset?.owner_jid || 'unknown');
|
|
65
|
+
const sha256 = String(asset?.sha256 || '').trim().toLowerCase();
|
|
66
|
+
if (!sha256) return '';
|
|
67
|
+
return `${OBJECT_STORAGE_KEY_PREFIX}/${ownerToken}/${sha256}.webp`;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const loadAwsSdk = async () => {
|
|
71
|
+
if (sdkLoadState.loaded) return sdkLoadState;
|
|
72
|
+
try {
|
|
73
|
+
const [{ S3Client, PutObjectCommand, GetObjectCommand }, { getSignedUrl }] = await Promise.all([
|
|
74
|
+
import('@aws-sdk/client-s3'),
|
|
75
|
+
import('@aws-sdk/s3-request-presigner'),
|
|
76
|
+
]);
|
|
77
|
+
sdkLoadState = {
|
|
78
|
+
loaded: true,
|
|
79
|
+
warned: false,
|
|
80
|
+
S3Client,
|
|
81
|
+
PutObjectCommand,
|
|
82
|
+
GetObjectCommand,
|
|
83
|
+
getSignedUrl,
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!sdkLoadState.warned) {
|
|
87
|
+
sdkLoadState.warned = true;
|
|
88
|
+
logger.warn('SDK AWS não disponível para object storage. Mantendo fallback local.', {
|
|
89
|
+
action: 'sticker_object_storage_sdk_unavailable',
|
|
90
|
+
error: error?.message,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
sdkLoadState = {
|
|
94
|
+
...sdkLoadState,
|
|
95
|
+
loaded: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return sdkLoadState;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const getS3Client = async () => {
|
|
102
|
+
if (!OBJECT_STORAGE_ENABLED || OBJECT_STORAGE_PROVIDER !== 's3') return null;
|
|
103
|
+
if (!OBJECT_STORAGE_BUCKET) return null;
|
|
104
|
+
if (s3Client) return s3Client;
|
|
105
|
+
|
|
106
|
+
const sdk = await loadAwsSdk();
|
|
107
|
+
if (!sdk?.S3Client) return null;
|
|
108
|
+
|
|
109
|
+
s3Client = new sdk.S3Client({
|
|
110
|
+
region: OBJECT_STORAGE_REGION,
|
|
111
|
+
endpoint: OBJECT_STORAGE_ENDPOINT || undefined,
|
|
112
|
+
forcePathStyle: OBJECT_STORAGE_FORCE_PATH_STYLE,
|
|
113
|
+
credentials:
|
|
114
|
+
OBJECT_STORAGE_ACCESS_KEY_ID && OBJECT_STORAGE_SECRET_ACCESS_KEY
|
|
115
|
+
? {
|
|
116
|
+
accessKeyId: OBJECT_STORAGE_ACCESS_KEY_ID,
|
|
117
|
+
secretAccessKey: OBJECT_STORAGE_SECRET_ACCESS_KEY,
|
|
118
|
+
}
|
|
119
|
+
: undefined,
|
|
120
|
+
});
|
|
121
|
+
return s3Client;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const streamToBuffer = async (body) => {
|
|
125
|
+
if (!body) return null;
|
|
126
|
+
if (Buffer.isBuffer(body)) return body;
|
|
127
|
+
if (typeof body?.transformToByteArray === 'function') {
|
|
128
|
+
const bytes = await body.transformToByteArray();
|
|
129
|
+
return Buffer.from(bytes);
|
|
130
|
+
}
|
|
131
|
+
if (typeof body?.getReader === 'function') {
|
|
132
|
+
const reader = body.getReader();
|
|
133
|
+
const chunks = [];
|
|
134
|
+
while (true) {
|
|
135
|
+
const { value, done } = await reader.read();
|
|
136
|
+
if (done) break;
|
|
137
|
+
if (value) chunks.push(Buffer.from(value));
|
|
138
|
+
}
|
|
139
|
+
return Buffer.concat(chunks);
|
|
140
|
+
}
|
|
141
|
+
if (typeof body?.on === 'function') {
|
|
142
|
+
const chunks = [];
|
|
143
|
+
return await new Promise((resolve, reject) => {
|
|
144
|
+
body.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
145
|
+
body.on('end', () => resolve(Buffer.concat(chunks)));
|
|
146
|
+
body.on('error', reject);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const isStickerObjectStorageEnabled = () =>
|
|
153
|
+
Boolean(OBJECT_STORAGE_ENABLED && OBJECT_STORAGE_PROVIDER === 's3' && OBJECT_STORAGE_BUCKET);
|
|
154
|
+
|
|
155
|
+
export const uploadStickerToObjectStorage = async ({
|
|
156
|
+
ownerJid,
|
|
157
|
+
sha256,
|
|
158
|
+
buffer,
|
|
159
|
+
mimetype = 'image/webp',
|
|
160
|
+
} = {}) => {
|
|
161
|
+
if (!OBJECT_STORAGE_UPLOAD_ON_WRITE || !Buffer.isBuffer(buffer) || !buffer.length) {
|
|
162
|
+
return { uploaded: false, key: null };
|
|
163
|
+
}
|
|
164
|
+
if (!isStickerObjectStorageEnabled()) {
|
|
165
|
+
return { uploaded: false, key: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const key = `${OBJECT_STORAGE_KEY_PREFIX}/${safeOwnerToken(ownerJid)}/${String(sha256 || '').trim().toLowerCase()}.webp`;
|
|
169
|
+
if (!key || key.endsWith('/.webp')) return { uploaded: false, key: null };
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const client = await getS3Client();
|
|
173
|
+
const sdk = await loadAwsSdk();
|
|
174
|
+
if (!client || !sdk?.PutObjectCommand) return { uploaded: false, key: null };
|
|
175
|
+
|
|
176
|
+
await client.send(
|
|
177
|
+
new sdk.PutObjectCommand({
|
|
178
|
+
Bucket: OBJECT_STORAGE_BUCKET,
|
|
179
|
+
Key: key,
|
|
180
|
+
Body: buffer,
|
|
181
|
+
ContentType: mimetype || 'image/webp',
|
|
182
|
+
CacheControl: 'public, max-age=31536000, immutable',
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return { uploaded: true, key };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.warn('Falha ao enviar sticker para object storage.', {
|
|
189
|
+
action: 'sticker_object_storage_upload_failed',
|
|
190
|
+
owner_jid: ownerJid || null,
|
|
191
|
+
error: error?.message,
|
|
192
|
+
});
|
|
193
|
+
return { uploaded: false, key: null };
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const getStickerObjectStorageUrl = async (
|
|
198
|
+
asset,
|
|
199
|
+
{
|
|
200
|
+
secure = true,
|
|
201
|
+
expiresInSeconds = 300,
|
|
202
|
+
} = {},
|
|
203
|
+
) => {
|
|
204
|
+
if (!isStickerObjectStorageEnabled()) return null;
|
|
205
|
+
|
|
206
|
+
const key = resolveStickerObjectKey(asset);
|
|
207
|
+
if (!key) return null;
|
|
208
|
+
|
|
209
|
+
if (OBJECT_STORAGE_CDN_BASE_URL) {
|
|
210
|
+
if (!secure) {
|
|
211
|
+
return `${OBJECT_STORAGE_CDN_BASE_URL}/${encodePathSegments(key)}`;
|
|
212
|
+
}
|
|
213
|
+
if (!OBJECT_STORAGE_SIGNED_URL_ENABLED) {
|
|
214
|
+
return `${OBJECT_STORAGE_CDN_BASE_URL}/${encodePathSegments(key)}`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!secure || !OBJECT_STORAGE_SIGNED_URL_ENABLED) {
|
|
219
|
+
if (OBJECT_STORAGE_CDN_BASE_URL) {
|
|
220
|
+
return `${OBJECT_STORAGE_CDN_BASE_URL}/${encodePathSegments(key)}`;
|
|
221
|
+
}
|
|
222
|
+
if (OBJECT_STORAGE_ENDPOINT) {
|
|
223
|
+
const base = OBJECT_STORAGE_ENDPOINT.replace(/\/+$/, '');
|
|
224
|
+
return `${base}/${encodeURIComponent(OBJECT_STORAGE_BUCKET)}/${encodePathSegments(key)}`;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const client = await getS3Client();
|
|
230
|
+
const sdk = await loadAwsSdk();
|
|
231
|
+
if (!client || !sdk?.GetObjectCommand || typeof sdk?.getSignedUrl !== 'function') return null;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const command = new sdk.GetObjectCommand({
|
|
235
|
+
Bucket: OBJECT_STORAGE_BUCKET,
|
|
236
|
+
Key: key,
|
|
237
|
+
});
|
|
238
|
+
return await sdk.getSignedUrl(client, command, {
|
|
239
|
+
expiresIn: Math.max(30, Math.min(3600 * 6, Number(expiresInSeconds) || 300)),
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
logger.warn('Falha ao gerar URL segura do object storage.', {
|
|
243
|
+
action: 'sticker_object_storage_signed_url_failed',
|
|
244
|
+
key,
|
|
245
|
+
error: error?.message,
|
|
246
|
+
});
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const readStickerFromObjectStorage = async (asset) => {
|
|
252
|
+
if (!isStickerObjectStorageEnabled()) return null;
|
|
253
|
+
const key = resolveStickerObjectKey(asset);
|
|
254
|
+
if (!key) return null;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const client = await getS3Client();
|
|
258
|
+
const sdk = await loadAwsSdk();
|
|
259
|
+
if (!client || !sdk?.GetObjectCommand) return null;
|
|
260
|
+
|
|
261
|
+
const response = await client.send(
|
|
262
|
+
new sdk.GetObjectCommand({
|
|
263
|
+
Bucket: OBJECT_STORAGE_BUCKET,
|
|
264
|
+
Key: key,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
return await streamToBuffer(response?.Body);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
logger.warn('Falha ao ler sticker no object storage.', {
|
|
270
|
+
action: 'sticker_object_storage_read_failed',
|
|
271
|
+
storage_path: asset?.storage_path || null,
|
|
272
|
+
error: error?.message,
|
|
273
|
+
});
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const toStickerStoragePath = ({ localPath, ownerJid, sha256 }) => {
|
|
279
|
+
const normalizedLocalPath = path.resolve(String(localPath || ''));
|
|
280
|
+
if (!isStickerObjectStorageEnabled()) return normalizedLocalPath;
|
|
281
|
+
if (!OBJECT_STORAGE_UPLOAD_ON_WRITE) return normalizedLocalPath;
|
|
282
|
+
const key = `${OBJECT_STORAGE_KEY_PREFIX}/${safeOwnerToken(ownerJid)}/${String(sha256 || '').trim().toLowerCase()}.webp`;
|
|
283
|
+
if (!key || key.endsWith('/.webp')) return normalizedLocalPath;
|
|
284
|
+
return `s3://${OBJECT_STORAGE_BUCKET}/${key}`;
|
|
285
|
+
};
|