strapi-content-sync-pro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/admin/src/components/ConfigTab.jsx +1038 -0
  4. package/admin/src/components/ContentTypesTab.jsx +160 -0
  5. package/admin/src/components/HelpTab.jsx +945 -0
  6. package/admin/src/components/LogsTab.jsx +136 -0
  7. package/admin/src/components/MediaTab.jsx +557 -0
  8. package/admin/src/components/SyncProfilesTab.jsx +715 -0
  9. package/admin/src/components/SyncTab.jsx +988 -0
  10. package/admin/src/index.js +31 -0
  11. package/admin/src/pages/App/index.jsx +129 -0
  12. package/admin/src/pluginId.js +3 -0
  13. package/package.json +84 -0
  14. package/server/src/bootstrap.js +151 -0
  15. package/server/src/config/index.js +5 -0
  16. package/server/src/content-types/index.js +7 -0
  17. package/server/src/content-types/sync-log/schema.json +24 -0
  18. package/server/src/controllers/alerts.js +59 -0
  19. package/server/src/controllers/config.js +292 -0
  20. package/server/src/controllers/content-type-discovery.js +9 -0
  21. package/server/src/controllers/dependencies.js +109 -0
  22. package/server/src/controllers/index.js +29 -0
  23. package/server/src/controllers/ping.js +7 -0
  24. package/server/src/controllers/sync-config.js +26 -0
  25. package/server/src/controllers/sync-enforcement.js +323 -0
  26. package/server/src/controllers/sync-execution.js +134 -0
  27. package/server/src/controllers/sync-log.js +18 -0
  28. package/server/src/controllers/sync-media.js +158 -0
  29. package/server/src/controllers/sync-profiles.js +182 -0
  30. package/server/src/controllers/sync.js +31 -0
  31. package/server/src/destroy.js +7 -0
  32. package/server/src/index.js +21 -0
  33. package/server/src/middlewares/verify-signature.js +32 -0
  34. package/server/src/register.js +7 -0
  35. package/server/src/routes/index.js +111 -0
  36. package/server/src/services/alerts.js +437 -0
  37. package/server/src/services/config.js +68 -0
  38. package/server/src/services/content-type-discovery.js +41 -0
  39. package/server/src/services/dependency-resolver.js +284 -0
  40. package/server/src/services/index.js +30 -0
  41. package/server/src/services/ping.js +7 -0
  42. package/server/src/services/sync-config.js +45 -0
  43. package/server/src/services/sync-enforcement.js +362 -0
  44. package/server/src/services/sync-execution.js +541 -0
  45. package/server/src/services/sync-log.js +56 -0
  46. package/server/src/services/sync-media.js +963 -0
  47. package/server/src/services/sync-profiles.js +380 -0
  48. package/server/src/services/sync.js +248 -0
  49. package/server/src/utils/applier.js +89 -0
  50. package/server/src/utils/comparator.js +83 -0
  51. package/server/src/utils/fetcher.js +142 -0
  52. package/server/src/utils/hmac.js +37 -0
  53. package/server/src/utils/pagination.js +51 -0
  54. package/server/src/utils/sync-guard.js +29 -0
  55. package/server/src/utils/sync-id.js +16 -0
@@ -0,0 +1,437 @@
1
+ 'use strict';
2
+
3
+ const STORE_KEY = 'sync-alerts-settings';
4
+
5
+ /**
6
+ * Sync Alerts Service
7
+ *
8
+ * Manages notifications for sync success/failure events.
9
+ * Supports:
10
+ * - Strapi built-in notifications (sync log)
11
+ * - Email notifications (using Strapi's email plugin - requires configuration)
12
+ * - Custom webhook notifications
13
+ *
14
+ * For email to work, you need to configure Strapi's email plugin:
15
+ * - @strapi/provider-email-sendgrid
16
+ * - @strapi/provider-email-mailgun
17
+ * - @strapi/provider-email-amazon-ses
18
+ * - @strapi/provider-email-nodemailer
19
+ *
20
+ * See: https://docs.strapi.io/dev-docs/providers#configuring-providers
21
+ */
22
+ module.exports = ({ strapi }) => {
23
+ function getStore() {
24
+ return strapi.store({ type: 'plugin', name: 'strapi-content-sync-pro' });
25
+ }
26
+
27
+ function plugin() {
28
+ return strapi.plugin('strapi-content-sync-pro');
29
+ }
30
+
31
+ const DEFAULT_ALERT_SETTINGS = {
32
+ enabled: true,
33
+ channels: {
34
+ strapiNotification: {
35
+ enabled: true,
36
+ onSuccess: false,
37
+ onFailure: true,
38
+ },
39
+ email: {
40
+ enabled: false,
41
+ onSuccess: false,
42
+ onFailure: true,
43
+ recipients: [],
44
+ // Optional: custom from address (uses Strapi email plugin default if not set)
45
+ from: '',
46
+ },
47
+ webhook: {
48
+ enabled: false,
49
+ onSuccess: true,
50
+ onFailure: true,
51
+ url: '',
52
+ headers: {},
53
+ },
54
+ },
55
+ throttle: {
56
+ enabled: true,
57
+ maxAlertsPerHour: 10,
58
+ },
59
+ };
60
+
61
+ // In-memory alert tracking for throttling
62
+ let alertHistory = [];
63
+
64
+ return {
65
+ /**
66
+ * Get alert settings
67
+ */
68
+ async getSettings() {
69
+ const store = getStore();
70
+ const data = await store.get({ key: STORE_KEY });
71
+ const settings = { ...DEFAULT_ALERT_SETTINGS };
72
+
73
+ if (data) {
74
+ Object.assign(settings, data);
75
+ // Deep merge channels
76
+ if (data.channels) {
77
+ settings.channels = {
78
+ ...DEFAULT_ALERT_SETTINGS.channels,
79
+ ...data.channels,
80
+ };
81
+ for (const channel of ['strapiNotification', 'email', 'webhook']) {
82
+ if (data.channels[channel]) {
83
+ settings.channels[channel] = {
84
+ ...DEFAULT_ALERT_SETTINGS.channels[channel],
85
+ ...data.channels[channel],
86
+ };
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Check if Strapi email plugin is configured
93
+ const emailPluginConfigured = this.isEmailPluginConfigured();
94
+ settings.emailPluginConfigured = emailPluginConfigured;
95
+
96
+ return settings;
97
+ },
98
+
99
+ /**
100
+ * Check if Strapi's email plugin is configured
101
+ */
102
+ isEmailPluginConfigured() {
103
+ try {
104
+ // Check if email service exists and has send method
105
+ return !!(strapi.plugin('email')?.service('email')?.send);
106
+ } catch {
107
+ return false;
108
+ }
109
+ },
110
+
111
+ /**
112
+ * Update alert settings
113
+ */
114
+ async updateSettings(updates) {
115
+ const store = getStore();
116
+ const storedData = await store.get({ key: STORE_KEY }) || {};
117
+
118
+ // Deep merge for nested channel settings
119
+ const newSettings = {
120
+ ...storedData,
121
+ ...updates,
122
+ };
123
+
124
+ if (updates.channels) {
125
+ newSettings.channels = {
126
+ ...(storedData.channels || {}),
127
+ ...updates.channels,
128
+ };
129
+
130
+ for (const channel of ['strapiNotification', 'email', 'webhook']) {
131
+ if (updates.channels[channel]) {
132
+ newSettings.channels[channel] = {
133
+ ...(storedData.channels?.[channel] || {}),
134
+ ...updates.channels[channel],
135
+ };
136
+ }
137
+ }
138
+ }
139
+
140
+ // Validate email settings if enabled
141
+ if (newSettings.channels?.email?.enabled) {
142
+ if (!newSettings.channels.email.recipients || newSettings.channels.email.recipients.length === 0) {
143
+ throw new Error('Email channel enabled but no recipients configured');
144
+ }
145
+ if (!this.isEmailPluginConfigured()) {
146
+ throw new Error('Email channel enabled but Strapi email plugin is not configured. Please install and configure an email provider (e.g., @strapi/provider-email-sendgrid, @strapi/provider-email-nodemailer)');
147
+ }
148
+ }
149
+
150
+ // Validate webhook URL
151
+ if (newSettings.channels?.webhook?.enabled && !newSettings.channels.webhook.url) {
152
+ throw new Error('Webhook channel enabled but no URL configured');
153
+ }
154
+
155
+ await store.set({ key: STORE_KEY, value: newSettings });
156
+
157
+ return newSettings;
158
+ },
159
+
160
+ /**
161
+ * Check if alerts are being throttled
162
+ */
163
+ isThrottled() {
164
+ const oneHourAgo = Date.now() - 3600000;
165
+ alertHistory = alertHistory.filter(ts => ts > oneHourAgo);
166
+ return alertHistory.length >= 10; // Default max
167
+ },
168
+
169
+ /**
170
+ * Record an alert for throttling
171
+ */
172
+ recordAlert() {
173
+ alertHistory.push(Date.now());
174
+ },
175
+
176
+ /**
177
+ * Send an alert through configured channels
178
+ */
179
+ async sendAlert(eventType, data) {
180
+ const store = getStore();
181
+ const settings = await store.get({ key: STORE_KEY }) || DEFAULT_ALERT_SETTINGS;
182
+
183
+ if (!settings.enabled) {
184
+ return { sent: false, reason: 'Alerts disabled' };
185
+ }
186
+
187
+ // Check throttling
188
+ if (settings.throttle?.enabled && this.isThrottled()) {
189
+ strapi.log.warn('Alert throttled - too many alerts in the past hour');
190
+ return { sent: false, reason: 'Throttled' };
191
+ }
192
+
193
+ const isSuccess = eventType === 'sync_success';
194
+ const isFailure = eventType === 'sync_failure';
195
+ const results = [];
196
+
197
+ // Strapi notification channel
198
+ if (settings.channels?.strapiNotification?.enabled) {
199
+ const shouldSend = (isSuccess && settings.channels.strapiNotification.onSuccess) ||
200
+ (isFailure && settings.channels.strapiNotification.onFailure);
201
+ if (shouldSend) {
202
+ try {
203
+ await this.sendStrapiNotification(eventType, data);
204
+ results.push({ channel: 'strapiNotification', success: true });
205
+ } catch (error) {
206
+ results.push({ channel: 'strapiNotification', success: false, error: error.message });
207
+ }
208
+ }
209
+ }
210
+
211
+ // Email channel
212
+ if (settings.channels?.email?.enabled) {
213
+ const shouldSend = (isSuccess && settings.channels.email.onSuccess) ||
214
+ (isFailure && settings.channels.email.onFailure);
215
+ if (shouldSend) {
216
+ try {
217
+ await this.sendEmailNotification(eventType, data, settings.channels.email);
218
+ results.push({ channel: 'email', success: true });
219
+ } catch (error) {
220
+ results.push({ channel: 'email', success: false, error: error.message });
221
+ }
222
+ }
223
+ }
224
+
225
+ // Webhook channel
226
+ if (settings.channels?.webhook?.enabled) {
227
+ const shouldSend = (isSuccess && settings.channels.webhook.onSuccess) ||
228
+ (isFailure && settings.channels.webhook.onFailure);
229
+ if (shouldSend) {
230
+ try {
231
+ await this.sendWebhookNotification(eventType, data, settings.channels.webhook);
232
+ results.push({ channel: 'webhook', success: true });
233
+ } catch (error) {
234
+ results.push({ channel: 'webhook', success: false, error: error.message });
235
+ }
236
+ }
237
+ }
238
+
239
+ if (results.length > 0) {
240
+ this.recordAlert();
241
+ }
242
+
243
+ return { sent: results.length > 0, results };
244
+ },
245
+
246
+ /**
247
+ * Send Strapi admin notification
248
+ */
249
+ async sendStrapiNotification(eventType, data) {
250
+ const logService = plugin().service('syncLog');
251
+ const isFailure = eventType === 'sync_failure';
252
+
253
+ // Log to sync log (visible in admin)
254
+ await logService.log({
255
+ action: isFailure ? 'sync_error' : 'sync_complete',
256
+ contentType: data.contentType || 'unknown',
257
+ direction: 'system',
258
+ status: isFailure ? 'error' : 'success',
259
+ message: this.formatMessage(eventType, data),
260
+ details: data,
261
+ });
262
+
263
+ strapi.log.info(`[Sync Alert] ${eventType}: ${this.formatMessage(eventType, data)}`);
264
+ },
265
+
266
+ /**
267
+ * Send email notification using Strapi's email plugin
268
+ */
269
+ async sendEmailNotification(eventType, data, emailConfig) {
270
+ if (!this.isEmailPluginConfigured()) {
271
+ throw new Error('Strapi email plugin is not configured');
272
+ }
273
+
274
+ const emailService = strapi.plugin('email').service('email');
275
+
276
+ const subject = eventType === 'sync_failure'
277
+ ? `[Sync Alert] Sync Failed - ${data.profile || data.contentType}`
278
+ : `[Sync Alert] Sync Completed - ${data.profile || data.contentType}`;
279
+
280
+ const html = this.formatEmailBody(eventType, data);
281
+ const text = this.formatMessage(eventType, data);
282
+
283
+ for (const recipient of emailConfig.recipients) {
284
+ const emailOptions = {
285
+ to: recipient,
286
+ subject,
287
+ html,
288
+ text,
289
+ };
290
+
291
+ // Only set from if explicitly configured
292
+ if (emailConfig.from) {
293
+ emailOptions.from = emailConfig.from;
294
+ }
295
+
296
+ await emailService.send(emailOptions);
297
+ }
298
+ },
299
+
300
+ /**
301
+ * Send webhook notification
302
+ */
303
+ async sendWebhookNotification(eventType, data, webhookConfig) {
304
+ const payload = {
305
+ event: eventType,
306
+ timestamp: new Date().toISOString(),
307
+ data,
308
+ };
309
+
310
+ const response = await fetch(webhookConfig.url, {
311
+ method: 'POST',
312
+ headers: {
313
+ 'Content-Type': 'application/json',
314
+ ...webhookConfig.headers,
315
+ },
316
+ body: JSON.stringify(payload),
317
+ });
318
+
319
+ if (!response.ok) {
320
+ throw new Error(`Webhook failed with status ${response.status}`);
321
+ }
322
+ },
323
+
324
+ /**
325
+ * Format alert message
326
+ */
327
+ formatMessage(eventType, data) {
328
+ if (eventType === 'sync_failure') {
329
+ return `Sync failed for ${data.profile || data.contentType}: ${data.error || 'Unknown error'}`;
330
+ }
331
+ if (eventType === 'sync_success') {
332
+ const duration = data.duration ? ` (${Math.round(data.duration / 1000)}s)` : '';
333
+ return `Sync completed for ${data.profile || data.contentType}${duration}`;
334
+ }
335
+ return `Sync event: ${eventType}`;
336
+ },
337
+
338
+ /**
339
+ * Format email body
340
+ */
341
+ formatEmailBody(eventType, data) {
342
+ const isFailure = eventType === 'sync_failure';
343
+ const statusColor = isFailure ? '#dc3545' : '#28a745';
344
+ const statusText = isFailure ? 'Failed' : 'Completed';
345
+
346
+ return `
347
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
348
+ <h2 style="color: ${statusColor};">Sync ${statusText}</h2>
349
+ <table style="width: 100%; border-collapse: collapse;">
350
+ <tr>
351
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>Profile:</strong></td>
352
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;">${data.profile || 'N/A'}</td>
353
+ </tr>
354
+ <tr>
355
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>Content Type:</strong></td>
356
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;">${data.contentType || 'N/A'}</td>
357
+ </tr>
358
+ <tr>
359
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>Time:</strong></td>
360
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;">${new Date().toISOString()}</td>
361
+ </tr>
362
+ ${data.duration ? `
363
+ <tr>
364
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>Duration:</strong></td>
365
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;">${Math.round(data.duration / 1000)}s</td>
366
+ </tr>
367
+ ` : ''}
368
+ ${isFailure ? `
369
+ <tr>
370
+ <td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>Error:</strong></td>
371
+ <td style="padding: 8px; border-bottom: 1px solid #ddd; color: #dc3545;">${data.error || 'Unknown error'}</td>
372
+ </tr>
373
+ ` : ''}
374
+ </table>
375
+ <p style="margin-top: 20px; color: #666; font-size: 12px;">
376
+ This is an automated notification from Strapi-to-Strapi Data Sync Plugin.
377
+ </p>
378
+ </div>
379
+ `;
380
+ },
381
+
382
+ /**
383
+ * Test alert channels
384
+ */
385
+ async testChannel(channel) {
386
+ const testData = {
387
+ profile: 'Test Profile',
388
+ contentType: 'api::test.test',
389
+ duration: 5000,
390
+ };
391
+
392
+ const store = getStore();
393
+ const settings = await store.get({ key: STORE_KEY }) || DEFAULT_ALERT_SETTINGS;
394
+
395
+ switch (channel) {
396
+ case 'strapiNotification':
397
+ await this.sendStrapiNotification('sync_success', testData);
398
+ return { success: true, message: 'Strapi notification sent - check sync logs' };
399
+
400
+ case 'email':
401
+ if (!this.isEmailPluginConfigured()) {
402
+ throw new Error('Strapi email plugin is not configured. Install and configure an email provider first.');
403
+ }
404
+ if (!settings.channels?.email?.recipients?.length) {
405
+ throw new Error('No email recipients configured');
406
+ }
407
+ await this.sendEmailNotification('sync_success', testData, settings.channels.email);
408
+ return { success: true, message: `Test email sent to: ${settings.channels.email.recipients.join(', ')}` };
409
+
410
+ case 'webhook':
411
+ if (!settings.channels?.webhook?.url) {
412
+ throw new Error('No webhook URL configured');
413
+ }
414
+ await this.sendWebhookNotification('sync_success', testData, settings.channels.webhook);
415
+ return { success: true, message: 'Webhook notification sent' };
416
+
417
+ default:
418
+ throw new Error(`Unknown channel: ${channel}`);
419
+ }
420
+ },
421
+
422
+ /**
423
+ * Get alert history summary
424
+ */
425
+ getAlertStats() {
426
+ const oneHourAgo = Date.now() - 3600000;
427
+ const recentAlerts = alertHistory.filter(ts => ts > oneHourAgo);
428
+
429
+ return {
430
+ alertsLastHour: recentAlerts.length,
431
+ throttleLimit: 10,
432
+ isThrottled: this.isThrottled(),
433
+ emailPluginConfigured: this.isEmailPluginConfigured(),
434
+ };
435
+ },
436
+ };
437
+ };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const STORE_KEY = 'remote-server-config';
4
+
5
+ const SENSITIVE_FIELDS = ['apiToken', 'sharedSecret'];
6
+
7
+ module.exports = ({ strapi }) => {
8
+ function getStore() {
9
+ return strapi.store({
10
+ type: 'plugin',
11
+ name: 'strapi-content-sync-pro',
12
+ });
13
+ }
14
+
15
+ return {
16
+ async getConfig({ safe = true } = {}) {
17
+ const store = getStore();
18
+ const data = await store.get({ key: STORE_KEY });
19
+
20
+ if (!data) {
21
+ return null;
22
+ }
23
+
24
+ if (!safe) {
25
+ return data;
26
+ }
27
+
28
+ const sanitized = { ...data };
29
+ for (const field of SENSITIVE_FIELDS) {
30
+ if (sanitized[field]) {
31
+ sanitized[field] = '••••••••';
32
+ }
33
+ }
34
+ return sanitized;
35
+ },
36
+
37
+ async setConfig(config) {
38
+ const store = getStore();
39
+
40
+ const existing = await store.get({ key: STORE_KEY }) || {};
41
+
42
+ const merged = { ...existing };
43
+
44
+ if (config.baseUrl !== undefined) {
45
+ merged.baseUrl = config.baseUrl;
46
+ }
47
+ if (config.apiToken !== undefined) {
48
+ merged.apiToken = config.apiToken;
49
+ }
50
+ if (config.syncDirection !== undefined) {
51
+ if (!['push', 'pull', 'bidirectional'].includes(config.syncDirection)) {
52
+ throw new Error('syncDirection must be "push", "pull", or "bidirectional"');
53
+ }
54
+ merged.syncDirection = config.syncDirection;
55
+ }
56
+ if (config.instanceId !== undefined) {
57
+ merged.instanceId = config.instanceId;
58
+ }
59
+ if (config.sharedSecret !== undefined) {
60
+ merged.sharedSecret = config.sharedSecret;
61
+ }
62
+
63
+ await store.set({ key: STORE_KEY, value: merged });
64
+
65
+ return merged;
66
+ },
67
+ };
68
+ };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const PRIMITIVE_TYPES = [
4
+ 'string', 'text', 'richtext', 'integer', 'biginteger',
5
+ 'float', 'decimal', 'boolean', 'date', 'datetime',
6
+ 'time', 'email', 'password', 'enumeration', 'uid', 'json',
7
+ ];
8
+
9
+ module.exports = ({ strapi }) => ({
10
+ /**
11
+ * Return every user-defined collection type that is eligible for sync.
12
+ * Excludes admin, upload, and users-permissions content types.
13
+ */
14
+ getSyncableContentTypes() {
15
+ const result = [];
16
+
17
+ for (const [uid, ct] of Object.entries(strapi.contentTypes)) {
18
+ if (!uid.startsWith('api::')) continue;
19
+ if (ct.kind !== 'collectionType') continue;
20
+
21
+ const primitiveAttributes = {};
22
+ for (const [name, attr] of Object.entries(ct.attributes || {})) {
23
+ if (PRIMITIVE_TYPES.includes(attr.type)) {
24
+ primitiveAttributes[name] = {
25
+ type: attr.type,
26
+ required: attr.required || false,
27
+ };
28
+ }
29
+ }
30
+
31
+ result.push({
32
+ uid,
33
+ kind: ct.kind,
34
+ displayName: ct.info?.displayName || uid,
35
+ attributes: primitiveAttributes,
36
+ });
37
+ }
38
+
39
+ return result;
40
+ },
41
+ });