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,963 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Sync Media Service
5
+ *
6
+ * Profile-based media synchronization between two Strapi instances.
7
+ * Each media profile defines direction, strategy, conflict resolution,
8
+ * file-type filters, and execution settings — mirroring how content-type
9
+ * sync profiles work.
10
+ *
11
+ * Strategies:
12
+ * 1. url — HTTP upload/download via /api/upload
13
+ * 2. rsync — file-level copy via rsync binary
14
+ * 3. disabled — no media sync
15
+ *
16
+ * Sync scope:
17
+ * - DB rows : plugin::upload.file metadata (name, caption, alt, mime, …)
18
+ * - File bytes: actual media assets (via URL or rsync)
19
+ * - Both can be toggled independently per profile.
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const fsp = require('node:fs/promises');
24
+ const path = require('node:path');
25
+ const { spawn } = require('node:child_process');
26
+ const { pipeline } = require('node:stream/promises');
27
+ const { Readable } = require('node:stream');
28
+
29
+ const PROFILES_KEY = 'media-sync-profiles';
30
+ const GLOBAL_SETTINGS_KEY = 'media-sync-global-settings';
31
+ const STATUS_KEY = 'media-sync-status';
32
+ const PLUGIN_NAME = 'strapi-content-sync-pro';
33
+
34
+ // ── Default MIME type groups ────────────────────────────────────────────────
35
+ const DEFAULT_MIME_IMAGES = ['image/'];
36
+ const DEFAULT_MIME_VIDEOS = [
37
+ 'video/mp4', 'video/webm', 'video/x-msvideo', 'video/quicktime',
38
+ 'video/x-matroska', 'video/ogg', 'video/3gpp',
39
+ ];
40
+ const DEFAULT_MIME_DOCUMENTS = [
41
+ 'application/pdf',
42
+ 'application/msword',
43
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
44
+ 'application/vnd.ms-excel',
45
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
46
+ 'application/vnd.ms-powerpoint',
47
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
48
+ 'application/vnd.oasis.opendocument.text',
49
+ 'application/vnd.oasis.opendocument.spreadsheet',
50
+ 'text/csv',
51
+ 'text/plain',
52
+ ];
53
+ const DEFAULT_MIME_ALL = [...DEFAULT_MIME_IMAGES, ...DEFAULT_MIME_VIDEOS, ...DEFAULT_MIME_DOCUMENTS];
54
+
55
+ const VALID_STRATEGIES = ['disabled', 'url', 'rsync'];
56
+ const VALID_DIRECTIONS = ['push', 'pull', 'both'];
57
+ const VALID_CONFLICT_STRATEGIES = ['latest_wins', 'local_wins', 'remote_wins'];
58
+ const VALID_EXECUTION_MODES = ['on_demand', 'scheduled', 'live'];
59
+ const VALID_SCHEDULE_TYPES = ['interval', 'timeout', 'cron', 'external'];
60
+
61
+ // ── Default global settings ─────────────────────────────────────────────────
62
+ const DEFAULT_GLOBAL_SETTINGS = {
63
+ pageSize: 50,
64
+ batchConcurrency: 2,
65
+ skipIfSameSize: true,
66
+ // rsync defaults
67
+ rsyncCommand: 'rsync',
68
+ rsyncArgs: '-avz --delete-after',
69
+ localMediaPath: '',
70
+ remoteMediaPath: '',
71
+ sshPort: 22,
72
+ sshIdentityFile: '',
73
+ rsyncTimeoutMs: 30 * 60 * 1000,
74
+ };
75
+
76
+ // ── Default media profile template ──────────────────────────────────────────
77
+ function makeDefaultProfile(overrides = {}) {
78
+ return {
79
+ id: overrides.id || `media-profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
80
+ name: overrides.name || 'Media Sync',
81
+ strategy: overrides.strategy || 'url',
82
+ direction: overrides.direction || 'both',
83
+ conflictStrategy: overrides.conflictStrategy || 'latest_wins',
84
+ active: overrides.active !== undefined ? overrides.active : false,
85
+ // What to sync
86
+ syncDbRows: overrides.syncDbRows !== undefined ? overrides.syncDbRows : true,
87
+ syncFileBytes: overrides.syncFileBytes !== undefined ? overrides.syncFileBytes : true,
88
+ // File-type filters
89
+ includeMime: overrides.includeMime || [],
90
+ excludeMime: overrides.excludeMime || [],
91
+ includePatterns: overrides.includePatterns || [],
92
+ excludePatterns: overrides.excludePatterns || [],
93
+ dryRun: overrides.dryRun || false,
94
+ // Execution settings (per profile, like content sync)
95
+ executionMode: overrides.executionMode || 'on_demand',
96
+ scheduleType: overrides.scheduleType || 'interval',
97
+ scheduleInterval: overrides.scheduleInterval || 60,
98
+ cronExpression: overrides.cronExpression || '',
99
+ enabled: overrides.enabled !== undefined ? overrides.enabled : true,
100
+ lastExecutedAt: overrides.lastExecutedAt || null,
101
+ nextExecutionAt: overrides.nextExecutionAt || null,
102
+ createdAt: overrides.createdAt || new Date().toISOString(),
103
+ updatedAt: overrides.updatedAt || new Date().toISOString(),
104
+ };
105
+ }
106
+
107
+ // Auto-generated default profiles when media sync is first enabled
108
+ function generateDefaultProfiles() {
109
+ return [
110
+ makeDefaultProfile({
111
+ id: 'media-full-push',
112
+ name: 'Full Push (Media)',
113
+ strategy: 'url',
114
+ direction: 'push',
115
+ conflictStrategy: 'local_wins',
116
+ active: false,
117
+ includeMime: [...DEFAULT_MIME_ALL],
118
+ syncDbRows: true,
119
+ syncFileBytes: true,
120
+ }),
121
+ makeDefaultProfile({
122
+ id: 'media-full-pull',
123
+ name: 'Full Pull (Media)',
124
+ strategy: 'url',
125
+ direction: 'pull',
126
+ conflictStrategy: 'remote_wins',
127
+ active: false,
128
+ includeMime: [...DEFAULT_MIME_ALL],
129
+ syncDbRows: true,
130
+ syncFileBytes: true,
131
+ }),
132
+ makeDefaultProfile({
133
+ id: 'media-bidirectional',
134
+ name: 'Bidirectional (Media)',
135
+ strategy: 'url',
136
+ direction: 'both',
137
+ conflictStrategy: 'latest_wins',
138
+ active: true,
139
+ includeMime: [...DEFAULT_MIME_ALL],
140
+ syncDbRows: true,
141
+ syncFileBytes: true,
142
+ }),
143
+ ];
144
+ }
145
+
146
+ module.exports = ({ strapi }) => {
147
+ const log = strapi.log;
148
+ const schedulerHandles = {};
149
+
150
+ function store() {
151
+ return strapi.store({ type: 'plugin', name: PLUGIN_NAME });
152
+ }
153
+
154
+ function plugin() {
155
+ return strapi.plugin(PLUGIN_NAME);
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Profile CRUD
160
+ // ---------------------------------------------------------------------------
161
+
162
+ async function getProfiles() {
163
+ const data = await store().get({ key: PROFILES_KEY });
164
+ if (!data || !Array.isArray(data) || data.length === 0) {
165
+ // First time — generate defaults
166
+ const defaults = generateDefaultProfiles();
167
+ await store().set({ key: PROFILES_KEY, value: defaults });
168
+ return defaults;
169
+ }
170
+ return data;
171
+ }
172
+
173
+ async function getProfile(profileId) {
174
+ const profiles = await getProfiles();
175
+ return profiles.find((p) => p.id === profileId) || null;
176
+ }
177
+
178
+ async function getActiveProfile() {
179
+ const profiles = await getProfiles();
180
+ return profiles.find((p) => p.active) || null;
181
+ }
182
+
183
+ async function createProfile(data) {
184
+ const profiles = await getProfiles();
185
+ const profile = makeDefaultProfile({ ...data, id: undefined });
186
+ profiles.push(profile);
187
+ await store().set({ key: PROFILES_KEY, value: profiles });
188
+ return profile;
189
+ }
190
+
191
+ async function updateProfile(profileId, data) {
192
+ const profiles = await getProfiles();
193
+ const idx = profiles.findIndex((p) => p.id === profileId);
194
+ if (idx === -1) throw new Error(`Media profile "${profileId}" not found`);
195
+ const updated = { ...profiles[idx], ...data, id: profileId, updatedAt: new Date().toISOString() };
196
+ validateProfile(updated);
197
+ profiles[idx] = updated;
198
+ await store().set({ key: PROFILES_KEY, value: profiles });
199
+ return updated;
200
+ }
201
+
202
+ async function deleteProfile(profileId) {
203
+ let profiles = await getProfiles();
204
+ profiles = profiles.filter((p) => p.id !== profileId);
205
+ await store().set({ key: PROFILES_KEY, value: profiles });
206
+ clearHandles(profileId);
207
+ return { success: true };
208
+ }
209
+
210
+ async function activateProfile(profileId) {
211
+ const profiles = await getProfiles();
212
+ for (const p of profiles) {
213
+ p.active = p.id === profileId;
214
+ }
215
+ await store().set({ key: PROFILES_KEY, value: profiles });
216
+ return profiles.find((p) => p.id === profileId);
217
+ }
218
+
219
+ function validateProfile(p) {
220
+ if (!VALID_STRATEGIES.includes(p.strategy)) throw new Error(`Invalid strategy "${p.strategy}"`);
221
+ if (!VALID_DIRECTIONS.includes(p.direction)) throw new Error(`Invalid direction "${p.direction}"`);
222
+ if (!VALID_CONFLICT_STRATEGIES.includes(p.conflictStrategy)) throw new Error(`Invalid conflict strategy "${p.conflictStrategy}"`);
223
+ if (!VALID_EXECUTION_MODES.includes(p.executionMode)) throw new Error(`Invalid execution mode "${p.executionMode}"`);
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Global settings
228
+ // ---------------------------------------------------------------------------
229
+
230
+ async function getGlobalSettings() {
231
+ const s = await store().get({ key: GLOBAL_SETTINGS_KEY });
232
+ return { ...DEFAULT_GLOBAL_SETTINGS, ...(s || {}) };
233
+ }
234
+
235
+ async function setGlobalSettings(partial) {
236
+ const current = await getGlobalSettings();
237
+ const merged = { ...current, ...partial, updatedAt: new Date().toISOString() };
238
+ await store().set({ key: GLOBAL_SETTINGS_KEY, value: merged });
239
+ return merged;
240
+ }
241
+
242
+ // Back-compat: old flat settings → global + active profile
243
+ async function getSettings() {
244
+ const global = await getGlobalSettings();
245
+ const active = await getActiveProfile();
246
+ return { ...global, ...(active || {}), profiles: await getProfiles() };
247
+ }
248
+
249
+ async function setSettings(partial) {
250
+ // Back-compat: writes to global settings
251
+ return setGlobalSettings(partial);
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Status
256
+ // ---------------------------------------------------------------------------
257
+
258
+ async function getStatus() {
259
+ const s = await store().get({ key: STATUS_KEY });
260
+ const profiles = await getProfiles();
261
+ return {
262
+ profiles: profiles.map((p) => ({
263
+ id: p.id,
264
+ name: p.name,
265
+ active: p.active,
266
+ executionMode: p.executionMode,
267
+ enabled: p.enabled,
268
+ lastExecutedAt: p.lastExecutedAt,
269
+ nextExecutionAt: p.nextExecutionAt,
270
+ running: !!(s && s.runningProfiles && s.runningProfiles[p.id]),
271
+ })),
272
+ lastRunAt: s?.lastRunAt || null,
273
+ lastResult: s?.lastResult || null,
274
+ running: s?.running || false,
275
+ };
276
+ }
277
+
278
+ async function setStatus(status) {
279
+ await store().set({ key: STATUS_KEY, value: status });
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Scheduler helpers (mirrors sync-execution patterns)
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function clearHandles(profileId) {
287
+ const h = schedulerHandles[profileId];
288
+ if (!h) return;
289
+ if (h.interval) clearInterval(h.interval);
290
+ if (h.timeout) clearTimeout(h.timeout);
291
+ if (h.cronJob && typeof h.cronJob.cancel === 'function') h.cronJob.cancel();
292
+ delete schedulerHandles[profileId];
293
+ }
294
+
295
+ async function updateScheduler(profile) {
296
+ clearHandles(profile.id);
297
+ if (!profile.enabled || profile.executionMode !== 'scheduled') return;
298
+ const type = profile.scheduleType || 'interval';
299
+ if (type === 'external') return; // nothing to schedule in-process
300
+
301
+ const ms = (profile.scheduleInterval || 60) * 60 * 1000;
302
+
303
+ if (type === 'interval') {
304
+ schedulerHandles[profile.id] = {
305
+ interval: setInterval(() => runProfile(profile.id).catch((e) => log.error(`[media-sched] ${e.message}`)), ms),
306
+ };
307
+ } else if (type === 'timeout') {
308
+ async function chain() {
309
+ try { await runProfile(profile.id); } catch (e) { log.error(`[media-sched] ${e.message}`); }
310
+ const p = await getProfile(profile.id);
311
+ if (p && p.enabled && p.executionMode === 'scheduled' && p.scheduleType === 'timeout') {
312
+ schedulerHandles[profile.id] = { timeout: setTimeout(chain, ms) };
313
+ }
314
+ }
315
+ schedulerHandles[profile.id] = { timeout: setTimeout(chain, ms) };
316
+ } else if (type === 'cron') {
317
+ try {
318
+ const cronTask = strapi.cron.add({ [profile.cronExpression]: () => runProfile(profile.id).catch((e) => log.error(`[media-sched] ${e.message}`)) });
319
+ schedulerHandles[profile.id] = { cronJob: cronTask };
320
+ } catch (e) {
321
+ log.warn(`[media-sched] cron add failed for ${profile.id}: ${e.message}`);
322
+ }
323
+ }
324
+ }
325
+
326
+ async function initializeSchedulers() {
327
+ const profiles = await getProfiles();
328
+ for (const p of profiles) {
329
+ if (p.enabled && p.executionMode === 'scheduled') {
330
+ await updateScheduler(p);
331
+ }
332
+ }
333
+ }
334
+
335
+ function stopAllSchedulers() {
336
+ for (const id of Object.keys(schedulerHandles)) clearHandles(id);
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // DB-row sync helpers (sync plugin::upload.file metadata without file bytes)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ async function syncDbRowPull(remoteFile, localFile, profile) {
344
+ // Update or create a local DB row for the remote file's metadata
345
+ const meta = {
346
+ name: remoteFile.name,
347
+ alternativeText: remoteFile.alternativeText || '',
348
+ caption: remoteFile.caption || '',
349
+ width: remoteFile.width || null,
350
+ height: remoteFile.height || null,
351
+ formats: remoteFile.formats || null,
352
+ mime: remoteFile.mime,
353
+ size: remoteFile.size,
354
+ ext: remoteFile.ext,
355
+ hash: remoteFile.hash,
356
+ url: remoteFile.url,
357
+ provider: remoteFile.provider || 'local',
358
+ folderPath: remoteFile.folderPath || '',
359
+ };
360
+
361
+ if (localFile) {
362
+ // Conflict resolution
363
+ if (profile.conflictStrategy === 'local_wins') return 'skipped';
364
+ if (profile.conflictStrategy === 'latest_wins') {
365
+ const remoteTs = new Date(remoteFile.updatedAt || 0).getTime();
366
+ const localTs = new Date(localFile.updatedAt || 0).getTime();
367
+ if (localTs >= remoteTs) return 'skipped';
368
+ }
369
+ await strapi.db.query('plugin::upload.file').update({ where: { id: localFile.id }, data: meta });
370
+ return 'updated';
371
+ }
372
+ await strapi.db.query('plugin::upload.file').create({ data: meta });
373
+ return 'created';
374
+ }
375
+
376
+ async function syncDbRowPush(localFile, remoteFile, profile, remoteConfig) {
377
+ const meta = {
378
+ name: localFile.name,
379
+ alternativeText: localFile.alternativeText || '',
380
+ caption: localFile.caption || '',
381
+ mime: localFile.mime,
382
+ size: localFile.size,
383
+ };
384
+
385
+ if (remoteFile) {
386
+ if (profile.conflictStrategy === 'remote_wins') return 'skipped';
387
+ if (profile.conflictStrategy === 'latest_wins') {
388
+ const localTs = new Date(localFile.updatedAt || 0).getTime();
389
+ const remoteTs = new Date(remoteFile.updatedAt || 0).getTime();
390
+ if (remoteTs >= localTs) return 'skipped';
391
+ }
392
+ // Update remote metadata via REST
393
+ try {
394
+ const url = new URL(`/api/upload/files/${remoteFile.id}`, remoteConfig.baseUrl);
395
+ const res = await fetch(url.toString(), {
396
+ method: 'PUT',
397
+ headers: {
398
+ Authorization: `Bearer ${remoteConfig.apiToken}`,
399
+ 'Content-Type': 'application/json',
400
+ },
401
+ body: JSON.stringify({ fileInfo: meta }),
402
+ });
403
+ if (!res.ok) return 'error';
404
+ } catch { return 'error'; }
405
+ return 'updated';
406
+ }
407
+ // New file — bytes must be pushed separately
408
+ return 'needs_bytes';
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // URL strategy
413
+ // ---------------------------------------------------------------------------
414
+
415
+ function passesFilters(file, profile) {
416
+ const mime = file.mime || '';
417
+ if (profile.includeMime?.length && !profile.includeMime.some((p) => mime.startsWith(p))) return false;
418
+ if (profile.excludeMime?.length && profile.excludeMime.some((p) => mime.startsWith(p))) return false;
419
+ const name = file.name || '';
420
+ if (profile.excludePatterns?.length && profile.excludePatterns.some((p) => globLike(p, name))) return false;
421
+ if (profile.includePatterns?.length && !profile.includePatterns.some((p) => globLike(p, name))) return false;
422
+ return true;
423
+ }
424
+
425
+ function globLike(pattern, name) {
426
+ // very small wildcard matcher: "*" -> ".*", "?" -> "."
427
+ const rx = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
428
+ return rx.test(name);
429
+ }
430
+
431
+ function indexBy(files, key) {
432
+ const map = new Map();
433
+ for (const f of files) {
434
+ const k = (f[key] || '').toString();
435
+ if (k) map.set(k, f);
436
+ }
437
+ return map;
438
+ }
439
+
440
+ /**
441
+ * List remote upload files, page by page.
442
+ * Uses Strapi's /api/upload/files endpoint.
443
+ */
444
+ async function* iterateRemoteFiles(remoteConfig, pageSize) {
445
+ let page = 1;
446
+ while (true) {
447
+ const url = new URL('/api/upload/files', remoteConfig.baseUrl);
448
+ url.searchParams.set('pagination[page]', String(page));
449
+ url.searchParams.set('pagination[pageSize]', String(pageSize));
450
+ url.searchParams.set('sort', 'updatedAt:asc');
451
+
452
+ const res = await fetch(url.toString(), {
453
+ headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
454
+ });
455
+ if (!res.ok) {
456
+ const body = await safeReadBody(res);
457
+ throw new Error(`Remote upload list failed (${res.status}): ${body}`);
458
+ }
459
+ const json = await res.json();
460
+ // Strapi v5 returns either { results, pagination } or a bare array
461
+ const results = Array.isArray(json) ? json : (json.results || json.data || []);
462
+ const pagination = Array.isArray(json) ? null : (json.pagination || json.meta?.pagination);
463
+
464
+ yield results;
465
+
466
+ const hasMore = pagination
467
+ ? page < (pagination.pageCount ?? (pagination.total ? Math.ceil(pagination.total / pageSize) : 1))
468
+ : results.length === pageSize;
469
+ if (!hasMore || results.length === 0) break;
470
+ page += 1;
471
+ }
472
+ }
473
+
474
+ async function* iterateLocalFiles(pageSize) {
475
+ let page = 1;
476
+ while (true) {
477
+ const results = await strapi.db.query('plugin::upload.file').findMany({
478
+ limit: pageSize,
479
+ offset: (page - 1) * pageSize,
480
+ orderBy: { updatedAt: 'asc' },
481
+ });
482
+ yield results || [];
483
+ if (!results || results.length < pageSize) break;
484
+ page += 1;
485
+ }
486
+ }
487
+
488
+ async function downloadToBuffer(remoteConfig, file) {
489
+ const fileUrl = absoluteUrl(remoteConfig.baseUrl, file.url);
490
+ const res = await fetch(fileUrl, {
491
+ headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
492
+ });
493
+ if (!res.ok) throw new Error(`Download failed for ${file.name}: ${res.status}`);
494
+ const ab = await res.arrayBuffer();
495
+ return Buffer.from(ab);
496
+ }
497
+
498
+ function absoluteUrl(baseUrl, url) {
499
+ if (!url) return baseUrl;
500
+ if (/^https?:\/\//i.test(url)) return url;
501
+ return new URL(url, baseUrl).toString();
502
+ }
503
+
504
+ async function safeReadBody(res) {
505
+ try { return await res.text(); } catch { return '<unreadable>'; }
506
+ }
507
+
508
+ async function uploadBufferToRemote(remoteConfig, file, buffer) {
509
+ const form = new FormData();
510
+ const blob = new Blob([buffer], { type: file.mime || 'application/octet-stream' });
511
+ form.append('files', blob, file.name);
512
+ if (file.folderPath) form.append('path', file.folderPath);
513
+
514
+ const res = await fetch(new URL('/api/upload', remoteConfig.baseUrl).toString(), {
515
+ method: 'POST',
516
+ headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
517
+ body: form,
518
+ });
519
+ if (!res.ok) {
520
+ const body = await safeReadBody(res);
521
+ throw new Error(`Upload failed for ${file.name}: ${res.status} ${body}`);
522
+ }
523
+ return res.json();
524
+ }
525
+
526
+ async function uploadBufferToLocal(file, buffer) {
527
+ // Write buffer to a temp file so the upload service can process it
528
+ // the same way it handles multipart form uploads.
529
+ const os = require('node:os');
530
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'strapi-sync-media-'));
531
+ const ext = path.extname(file.name) || '';
532
+ const tmpFilePath = path.join(tmpDir, `upload${ext}`);
533
+ await fsp.writeFile(tmpFilePath, buffer);
534
+
535
+ const uploadService = strapi.plugin('upload').service('upload');
536
+ try {
537
+ const fileObj = {
538
+ filepath: tmpFilePath,
539
+ originalFilename: file.name,
540
+ mimetype: file.mime || 'application/octet-stream',
541
+ size: buffer.length,
542
+ };
543
+ const result = await uploadService.upload({
544
+ data: {
545
+ fileInfo: {
546
+ name: file.name,
547
+ caption: file.caption || '',
548
+ alternativeText: file.alternativeText || '',
549
+ },
550
+ },
551
+ files: fileObj,
552
+ });
553
+ return result;
554
+ } finally {
555
+ await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
556
+ }
557
+ }
558
+
559
+ function shouldSkip(localFile, remoteFile, settings) {
560
+ if (!localFile || !remoteFile) return false;
561
+ if (settings.skipIfSameSize && localFile.size === remoteFile.size && localFile.hash === remoteFile.hash) {
562
+ return true;
563
+ }
564
+ return false;
565
+ }
566
+
567
+ /**
568
+ * Copy a page of files respecting settings.batchConcurrency.
569
+ */
570
+ async function processBatch(items, worker, concurrency) {
571
+ const out = { success: 0, skipped: 0, errors: [] };
572
+ const c = Math.max(1, Math.min(concurrency || 1, 10));
573
+ let i = 0;
574
+ async function run() {
575
+ while (i < items.length) {
576
+ const idx = i++;
577
+ const item = items[idx];
578
+ try {
579
+ const r = await worker(item);
580
+ if (r === 'skipped') out.skipped++; else out.success++;
581
+ } catch (err) {
582
+ out.errors.push({ name: item?.name || String(idx), error: err.message });
583
+ }
584
+ }
585
+ }
586
+ await Promise.all(Array.from({ length: c }, run));
587
+ return out;
588
+ }
589
+
590
+ async function syncMediaViaUrl(profile, globalSettings) {
591
+ const settings = { ...globalSettings, ...profile };
592
+ const configService = plugin().service('config');
593
+ const logService = plugin().service('syncLog');
594
+ const remoteConfig = await configService.getConfig({ safe: false });
595
+ if (!remoteConfig?.baseUrl) throw new Error('Remote server not configured');
596
+
597
+ const totals = { pushed: 0, pulled: 0, skipped: 0, dbRowsUpdated: 0, errors: [] };
598
+ const started = Date.now();
599
+
600
+ const localIndex = new Map();
601
+ for await (const batch of iterateLocalFiles(settings.pageSize)) {
602
+ for (const f of batch) localIndex.set(`${f.hash}|${f.name}`, f);
603
+ }
604
+
605
+ // PULL: remote -> local
606
+ if (settings.direction === 'pull' || settings.direction === 'both') {
607
+ for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
608
+ const filtered = remoteBatch.filter((f) => passesFilters(f, profile));
609
+ const result = await processBatch(filtered, async (rf) => {
610
+ const key = `${rf.hash}|${rf.name}`;
611
+ const lf = localIndex.get(key);
612
+
613
+ // DB-row sync
614
+ if (profile.syncDbRows) {
615
+ const dbResult = await syncDbRowPull(rf, lf, profile);
616
+ if (dbResult === 'created' || dbResult === 'updated') totals.dbRowsUpdated++;
617
+ }
618
+
619
+ // File-byte sync
620
+ if (profile.syncFileBytes) {
621
+ if (shouldSkip(lf, rf, settings)) return 'skipped';
622
+ if (settings.dryRun) return 'success';
623
+ const buf = await downloadToBuffer(remoteConfig, rf);
624
+ await uploadBufferToLocal(rf, buf);
625
+ }
626
+ return 'success';
627
+ }, settings.batchConcurrency);
628
+ totals.pulled += result.success;
629
+ totals.skipped += result.skipped;
630
+ totals.errors.push(...result.errors);
631
+ }
632
+ }
633
+
634
+ // PUSH: local -> remote
635
+ if (settings.direction === 'push' || settings.direction === 'both') {
636
+ const remoteIndex = new Map();
637
+ for await (const remoteBatch of iterateRemoteFiles(remoteConfig, settings.pageSize)) {
638
+ for (const f of remoteBatch) remoteIndex.set(`${f.hash}|${f.name}`, f);
639
+ }
640
+
641
+ for await (const localBatch of iterateLocalFiles(settings.pageSize)) {
642
+ const filtered = localBatch.filter((f) => passesFilters(f, profile));
643
+ const result = await processBatch(filtered, async (lf) => {
644
+ const key = `${lf.hash}|${lf.name}`;
645
+ const rf = remoteIndex.get(key);
646
+
647
+ // DB-row sync (push metadata)
648
+ if (profile.syncDbRows && rf) {
649
+ const dbResult = await syncDbRowPush(lf, rf, profile, remoteConfig);
650
+ if (dbResult === 'updated') totals.dbRowsUpdated++;
651
+ }
652
+
653
+ // File-byte sync
654
+ if (profile.syncFileBytes) {
655
+ if (shouldSkip(lf, rf, settings)) return 'skipped';
656
+ if (settings.dryRun) return 'success';
657
+ const buf = await readLocalFileBuffer(lf);
658
+ if (!buf) return 'skipped';
659
+ await uploadBufferToRemote(remoteConfig, lf, buf);
660
+ }
661
+ return 'success';
662
+ }, settings.batchConcurrency);
663
+ totals.pushed += result.success;
664
+ totals.skipped += result.skipped;
665
+ totals.errors.push(...result.errors);
666
+ }
667
+ }
668
+
669
+ const summary = {
670
+ strategy: 'url',
671
+ profileId: profile.id,
672
+ profileName: profile.name,
673
+ direction: settings.direction,
674
+ dryRun: !!settings.dryRun,
675
+ durationMs: Date.now() - started,
676
+ ...totals,
677
+ };
678
+
679
+ await logService?.log?.({
680
+ action: 'media_sync',
681
+ contentType: 'plugin::upload.file',
682
+ direction: settings.direction,
683
+ status: totals.errors.length ? 'partial' : 'success',
684
+ message: `URL media sync [${profile.name}]: pushed=${totals.pushed}, pulled=${totals.pulled}, dbRows=${totals.dbRowsUpdated}, skipped=${totals.skipped}, errors=${totals.errors.length}`,
685
+ details: summary,
686
+ });
687
+
688
+ return summary;
689
+ }
690
+
691
+ async function readLocalFileBuffer(file) {
692
+ // Only works for the local upload provider. For remote providers we'd
693
+ // have to fetch via file.url — which is supported too.
694
+ if (file.provider && file.provider !== 'local' && file.url) {
695
+ try {
696
+ const res = await fetch(file.url);
697
+ if (!res.ok) return null;
698
+ const ab = await res.arrayBuffer();
699
+ return Buffer.from(ab);
700
+ } catch {
701
+ return null;
702
+ }
703
+ }
704
+ const uploadsDir = path.join(strapi.dirs?.static?.public || path.join(process.cwd(), 'public'), 'uploads');
705
+ const filename = file.hash && file.ext ? `${file.hash}${file.ext}` : file.name;
706
+ const full = path.join(uploadsDir, filename);
707
+ try {
708
+ return await fsp.readFile(full);
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // rsync strategy
716
+ // ---------------------------------------------------------------------------
717
+
718
+ function buildRsyncArgs(settings, mode) {
719
+ const args = (settings.rsyncArgs || '-avz').trim().split(/\s+/).filter(Boolean);
720
+
721
+ // SSH options if remote path looks like user@host:/path
722
+ const isRemote = /:/.test(settings.remoteMediaPath) && !/^[A-Za-z]:\\/.test(settings.remoteMediaPath);
723
+ if (isRemote && (settings.sshPort !== 22 || settings.sshIdentityFile)) {
724
+ const parts = ['ssh'];
725
+ if (settings.sshPort && settings.sshPort !== 22) parts.push('-p', String(settings.sshPort));
726
+ if (settings.sshIdentityFile) parts.push('-i', settings.sshIdentityFile);
727
+ args.push('-e', parts.join(' '));
728
+ }
729
+
730
+ for (const p of settings.includePatterns || []) args.push('--include', p);
731
+ for (const p of settings.excludePatterns || []) args.push('--exclude', p);
732
+
733
+ if (settings.dryRun) args.push('--dry-run');
734
+
735
+ const src = mode === 'push' ? ensureTrailingSlash(settings.localMediaPath) : ensureTrailingSlash(settings.remoteMediaPath);
736
+ const dst = mode === 'push' ? settings.remoteMediaPath : settings.localMediaPath;
737
+ args.push(src, dst);
738
+
739
+ return args;
740
+ }
741
+
742
+ function ensureTrailingSlash(p) {
743
+ if (!p) return p;
744
+ return p.endsWith('/') || p.endsWith('\\') ? p : p + '/';
745
+ }
746
+
747
+ function runRsync(settings, mode) {
748
+ return new Promise((resolve, reject) => {
749
+ const cmd = settings.rsyncCommand || 'rsync';
750
+ const args = buildRsyncArgs(settings, mode);
751
+ log.info(`[data-sync] rsync ${mode}: ${cmd} ${args.join(' ')}`);
752
+
753
+ const child = spawn(cmd, args, { shell: false });
754
+ let stdout = '';
755
+ let stderr = '';
756
+ const timeout = setTimeout(() => {
757
+ try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
758
+ reject(new Error(`rsync timed out after ${settings.rsyncTimeoutMs}ms`));
759
+ }, settings.rsyncTimeoutMs || 30 * 60 * 1000);
760
+
761
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
762
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
763
+ child.on('error', (err) => { clearTimeout(timeout); reject(err); });
764
+ child.on('close', (code) => {
765
+ clearTimeout(timeout);
766
+ if (code === 0) resolve({ mode, stdout, stderr });
767
+ else reject(new Error(`rsync exited with code ${code}: ${stderr || stdout}`));
768
+ });
769
+ });
770
+ }
771
+
772
+ async function syncMediaViaRsync(profile, globalSettings) {
773
+ const settings = { ...globalSettings, ...profile };
774
+ const logService = plugin().service('syncLog');
775
+ const started = Date.now();
776
+ const results = [];
777
+
778
+ if (settings.direction === 'push' || settings.direction === 'both') {
779
+ results.push(await runRsync(settings, 'push'));
780
+ }
781
+ if (settings.direction === 'pull' || settings.direction === 'both') {
782
+ results.push(await runRsync(settings, 'pull'));
783
+ }
784
+
785
+ const summary = {
786
+ strategy: 'rsync',
787
+ profileId: profile.id,
788
+ profileName: profile.name,
789
+ direction: settings.direction,
790
+ dryRun: !!settings.dryRun,
791
+ durationMs: Date.now() - started,
792
+ runs: results.map((r) => ({ mode: r.mode, stdoutTail: tail(r.stdout), stderrTail: tail(r.stderr) })),
793
+ };
794
+
795
+ await logService?.log?.({
796
+ action: 'media_sync',
797
+ contentType: 'plugin::upload.file',
798
+ direction: settings.direction,
799
+ status: 'success',
800
+ message: `rsync media sync [${profile.name}] (${settings.direction}) completed in ${summary.durationMs}ms`,
801
+ details: summary,
802
+ });
803
+
804
+ return summary;
805
+ }
806
+
807
+ function tail(text, lines = 20) {
808
+ if (!text) return '';
809
+ const arr = text.split(/\r?\n/);
810
+ return arr.slice(Math.max(0, arr.length - lines)).join('\n');
811
+ }
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Profile execution
815
+ // ---------------------------------------------------------------------------
816
+
817
+ async function runProfile(profileId, options = {}) {
818
+ const profile = await getProfile(profileId);
819
+ if (!profile) throw new Error(`Media profile "${profileId}" not found`);
820
+ if (profile.strategy === 'disabled') throw new Error(`Profile "${profile.name}" has strategy disabled.`);
821
+
822
+ const globalSettings = await getGlobalSettings();
823
+ const merged = { ...globalSettings, ...profile, ...options };
824
+
825
+ const statusData = await store().get({ key: STATUS_KEY }) || {};
826
+ statusData.running = true;
827
+ statusData.runningProfiles = { ...(statusData.runningProfiles || {}), [profileId]: true };
828
+ await setStatus(statusData);
829
+
830
+ try {
831
+ let result;
832
+ if (merged.strategy === 'rsync') {
833
+ result = await syncMediaViaRsync(merged, globalSettings);
834
+ } else {
835
+ result = await syncMediaViaUrl(merged, globalSettings);
836
+ }
837
+
838
+ // Update profile last execution
839
+ await updateProfile(profileId, { lastExecutedAt: new Date().toISOString() });
840
+
841
+ const s2 = await store().get({ key: STATUS_KEY }) || {};
842
+ delete (s2.runningProfiles || {})[profileId];
843
+ s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
844
+ s2.lastRunAt = new Date().toISOString();
845
+ s2.lastResult = result;
846
+ await setStatus(s2);
847
+
848
+ return result;
849
+ } catch (err) {
850
+ const s2 = await store().get({ key: STATUS_KEY }) || {};
851
+ delete (s2.runningProfiles || {})[profileId];
852
+ s2.running = Object.keys(s2.runningProfiles || {}).length > 0;
853
+ s2.lastRunAt = new Date().toISOString();
854
+ s2.lastResult = { error: err.message };
855
+ await setStatus(s2);
856
+ throw err;
857
+ }
858
+ }
859
+
860
+ async function runActiveProfiles() {
861
+ const profiles = await getProfiles();
862
+ const active = profiles.filter((p) => p.active && p.strategy !== 'disabled');
863
+ const results = [];
864
+ for (const p of active) {
865
+ try {
866
+ results.push(await runProfile(p.id));
867
+ } catch (err) {
868
+ results.push({ profileId: p.id, error: err.message });
869
+ }
870
+ }
871
+ return results;
872
+ }
873
+
874
+ // ---------------------------------------------------------------------------
875
+ // Public API
876
+ // ---------------------------------------------------------------------------
877
+
878
+ return {
879
+ // Profile CRUD
880
+ getProfiles,
881
+ getProfile,
882
+ getActiveProfile,
883
+ createProfile,
884
+ updateProfile,
885
+ deleteProfile,
886
+ activateProfile,
887
+
888
+ // Global settings
889
+ getGlobalSettings,
890
+ setGlobalSettings,
891
+
892
+ // Back-compat
893
+ getSettings,
894
+ setSettings,
895
+
896
+ // Status
897
+ getStatus,
898
+
899
+ // Execution
900
+ runProfile,
901
+ runActiveProfiles,
902
+
903
+ // Schedulers
904
+ initializeSchedulers,
905
+ stopAllSchedulers,
906
+ updateScheduler,
907
+
908
+ // Constants for UI
909
+ getDefaults() {
910
+ return {
911
+ mimeImages: DEFAULT_MIME_IMAGES,
912
+ mimeVideos: DEFAULT_MIME_VIDEOS,
913
+ mimeDocuments: DEFAULT_MIME_DOCUMENTS,
914
+ mimeAll: DEFAULT_MIME_ALL,
915
+ strategies: VALID_STRATEGIES,
916
+ directions: VALID_DIRECTIONS,
917
+ conflictStrategies: VALID_CONFLICT_STRATEGIES,
918
+ executionModes: VALID_EXECUTION_MODES,
919
+ scheduleTypes: VALID_SCHEDULE_TYPES,
920
+ };
921
+ },
922
+
923
+ // Legacy run (back-compat for existing /media-sync/run endpoint)
924
+ async run(options = {}) {
925
+ if (options.profileId) {
926
+ return runProfile(options.profileId, options);
927
+ }
928
+ // Run the active profile
929
+ const active = await getActiveProfile();
930
+ if (!active) throw new Error('No active media profile. Create or activate one in the Media tab.');
931
+ return runProfile(active.id, options);
932
+ },
933
+
934
+ async testConnection() {
935
+ const globalSettings = await getGlobalSettings();
936
+ if (globalSettings.rsyncCommand && globalSettings.localMediaPath) {
937
+ // rsync test
938
+ return new Promise((resolve) => {
939
+ const child = spawn(globalSettings.rsyncCommand || 'rsync', ['--version'], { shell: false });
940
+ let out = '';
941
+ child.stdout.on('data', (d) => { out += d.toString(); });
942
+ child.on('error', (err) => resolve({ ok: false, error: err.message }));
943
+ child.on('close', (code) => resolve({ ok: code === 0, version: out.split(/\r?\n/)[0] || '' }));
944
+ });
945
+ }
946
+ // URL strategy test
947
+ const configService = plugin().service('config');
948
+ const remoteConfig = await configService.getConfig({ safe: false });
949
+ if (!remoteConfig?.baseUrl) return { ok: false, error: 'Remote server not configured' };
950
+ try {
951
+ const url = new URL('/api/upload/files', remoteConfig.baseUrl);
952
+ url.searchParams.set('pagination[pageSize]', '1');
953
+ const res = await fetch(url.toString(), {
954
+ headers: { Authorization: `Bearer ${remoteConfig.apiToken}` },
955
+ });
956
+ if (!res.ok) return { ok: false, error: `Remote ${res.status}: ${await safeReadBody(res)}` };
957
+ return { ok: true };
958
+ } catch (err) {
959
+ return { ok: false, error: err.message };
960
+ }
961
+ },
962
+ };
963
+ };